diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34588e29..e405e5b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,22 @@ jobs: uses: ros-tooling/setup-ros@v0.7 with: required-ros-distributions: ${{ matrix.ros-distro }} + # Get bt_tools TODO: remove after the release of bt_tools + - name: Checkout bt_tools + uses: actions/checkout@v2 + with: + repository: boschresearch/bt_tools + ref: feature/fsm_conversion + path: colcon_ws/src/bt_tools + # Compile bt_tools TODO: remove after the release of bt_tools + - name: Compile bt_tools + run: | + source /opt/ros/${{ matrix.ros-distro }}/setup.bash + # Install dependencies + cd colcon_ws + rosdep update && rosdep install --from-paths src --ignore-src -y + # Build and install bt_tools + colcon build --symlink-install # Get smc_storm for testing - name: Get smc_storm id: get_smc_storm @@ -37,6 +53,7 @@ jobs: # install the packages - name: Install packages run: | + source colcon_ws/install/setup.bash # TODO: remove after the release of bt_tools pip install jani_generator/.[dev] pip install mc_toolchain_jani_common/.[dev] pip install scxml_converter/.[dev] @@ -46,5 +63,6 @@ jobs: - name: Run tests run: | export PATH=$PATH:${{ steps.get_smc_storm.outputs.SMC_STORM_PATH }} - source /opt/ros/${{ matrix.ros-distro }}/setup.bash - pytest-3 -vs + # source /opt/ros/${{ matrix.ros-distro }}/setup.bash + source colcon_ws/install/setup.bash # TODO: remove after the release of bt_tools + pytest-3 -vs mc_toolchain_jani_common jani_generator scxml_converter diff --git a/README.md b/README.md index 9e549953..748cd55b 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,4 @@ Feedback is highly appreciated. Please open issues on new ideas, bugs, etc. here ### License -mc-toolchain-jani comes under the Apache-2.0 license, see [LICENSE](./LICENSE). \ No newline at end of file +mc-toolchain-jani comes under the Apache-2.0 license, see [LICENSE](./LICENSE). diff --git a/docs/source/graphics/scxml_if_handling.drawio.png b/docs/source/graphics/scxml_if_handling.drawio.png new file mode 100644 index 00000000..18bc9a91 Binary files /dev/null and b/docs/source/graphics/scxml_if_handling.drawio.png differ diff --git a/docs/source/graphics/scxml_to_jani.drawio.png b/docs/source/graphics/scxml_to_jani.drawio.png new file mode 100644 index 00000000..39007b3b Binary files /dev/null and b/docs/source/graphics/scxml_to_jani.drawio.png differ diff --git a/graphics b/graphics new file mode 120000 index 00000000..1183e149 --- /dev/null +++ b/graphics @@ -0,0 +1 @@ +docs/source/graphics/ \ No newline at end of file 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 9f3f3bba..03c37c67 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_constant.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_constant.py @@ -36,7 +36,7 @@ def name(self) -> str: def value(self) -> JaniValue: assert self._value is not None, "Value not set" jani_value = self._value.value - assert jani_value is not None and jani_value.is_valid(), "The expression cannot be evaluated to a constant value" + assert jani_value is not None and jani_value.is_valid(), "The expression can't be evaluated to a constant value" return jani_value.value() def jani_type_from_string(str_type: str) -> ValidTypes: @@ -52,6 +52,7 @@ def jani_type_from_string(str_type: str) -> ValidTypes: else: raise ValueError(f"Type {str_type} not supported by Jani") + # TODO: Move this to a util function file def jani_type_to_string(c_type: ValidTypes) -> str: """ Translate a Python type to the name of the type in Jani. @@ -68,14 +69,14 @@ def jani_type_to_string(c_type: ValidTypes) -> str: src https://docs.google.com/document/d/\ 1BDQIzPBtscxJFFlDUEPIo8ivKHgXT8_X6hz5quq7jK0/edit """ + assert isinstance(c_type, type), f"Type {c_type} is not a type" if c_type == bool: return "bool" - elif c_type == int: + if c_type == int: return "int" - elif c_type == float: + if c_type == float: return "real" - else: - raise ValueError(f"Type {c_type} not supported by Jani") + raise ValueError(f"Type {c_type} not supported by Jani") def as_dict(self): return { 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 e46ef503..9d674888 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_expression.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_expression.py @@ -17,18 +17,18 @@ Expressions in Jani """ -from typing import Union +from typing import Dict, Union from jani_generator.jani_entries import JaniValue SupportedExp = Union[str, int, float, bool, dict] class JaniExpression: - def __init__(self, expression): + def __init__(self, expression: Union[SupportedExp, 'JaniExpression', JaniValue]): self.identifier: str = None self.value: JaniValue = None self.op = None - self.operands = None + self.operands: Dict[str, Union[JaniExpression, JaniValue]] = None if isinstance(expression, JaniExpression): self.identifier = expression.identifier self.value = expression.value @@ -46,7 +46,8 @@ def __init__(self, expression): # If it is a value, then we don't need to expand further self.value = JaniValue(expression) else: - # If it isn't a value or an identifier, it must be a dictionary providing op and related operands + # If it isn't a value or an identifier, it must be a dictionary providing op and + # related operands # Operands need to be expanded further, until we encounter a value expression assert isinstance(expression, dict), "Expected a dictionary" assert "op" in expression, "Expected either a value or an operator" @@ -55,9 +56,11 @@ def __init__(self, expression): def _get_operands(self, expression_dict: dict): if (self.op in ("intersect", "distance")): - # intersect: returns a value in [0.0, 1.0], indicating where on the robot trajectory the intersection occurs. - # 0.0 means no intersection occurs (destination reached), 1.0 means the intersection occurs at the start - # distance: returns the distance between the robot and the barrier + # intersect: returns a value in [0.0, 1.0], indicating where on the robot trajectory + # the intersection occurs. + # 0.0 means no intersection occurs (destination reached), 1.0 means the + # intersection occurs at the start distance: returns the distance between the robot and + # the barrier. return { "robot": JaniExpression(expression_dict["robot"]), "barrier": JaniExpression(expression_dict["barrier"])} @@ -75,7 +78,8 @@ def _get_operands(self, expression_dict: dict): return { "left": JaniExpression(expression_dict["left"]), "right": JaniExpression(expression_dict["right"])} - if (self.op in ("!", "¬", "sin", "cos", "floor", "ceil", "abs", "to_cm", "to_m", "to_deg", "to_rad")): + if (self.op in ("!", "¬", "sin", "cos", "floor", "ceil", + "abs", "to_cm", "to_m", "to_deg", "to_rad")): return { "exp": JaniExpression(expression_dict["exp"])} if (self.op in ("ite")): @@ -96,13 +100,27 @@ def _get_operands(self, expression_dict: dict): assert False, f"Unknown operator \"{self.op}\" found." def replace_event(self, replacement): + """Replace `_event` with `replacement`. + + Within a transitions, scxml can access data of events from the `_event` variable. We + have to replace this by the global variable where we stored the data from the received + event. + + :param replacement: The string to replace `_event` with. + :return self: for the convenience of chain-ability + """ + if replacement is None: + # No replacement needed! + return self if self.identifier is not None: self.identifier = self.identifier.replace("_event", replacement) - return + return self if self.value is not None: - return + return self for operand in self.operands.values(): - operand.replace_event(replacement) + if isinstance(operand, JaniExpression): + operand.replace_event(replacement) + return self def is_valid(self) -> bool: return self.identifier is not None or self.value is not None or self.op is not None @@ -117,7 +135,8 @@ def as_dict(self) -> Union[str, int, float, bool, dict]: "op": self.op, } for op_key, op_value in self.operands.items(): - assert isinstance(op_value, JaniExpression), f"Expected an expression, found {type(op_value)} for {op_key}" + assert isinstance(op_value, JaniExpression), \ + f"Expected an expression, found {type(op_value)} for {op_key}" assert hasattr(op_value, "identifier"), f"Identifier not set for {op_key}" op_dict.update({op_key: op_value.as_dict()}) return op_dict 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 5688caf1..5ce6e68c 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 @@ -96,6 +96,10 @@ def not_equal_operator(left, right) -> JaniExpression: # Logic operators +def not_operator(exp) -> JaniExpression: + return JaniExpression({"op": "¬", "exp": exp}) + + def and_operator(left, right) -> JaniExpression: return JaniExpression({"op": "∧", "left": left, "right": right}) 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 7389dcee..cf5c4d49 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_guard.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_guard.py @@ -22,6 +22,7 @@ from jani_generator.jani_entries.jani_expression import JaniExpression + class JaniGuard: def __init__(self, expression: Optional[JaniExpression]): self.expression = expression @@ -30,9 +31,8 @@ def as_dict(self, constants: Optional[dict] = None): d = {} if self.expression: exp = self.expression.as_dict() - if (isinstance(exp, dict) and - list(exp.keys()) == ['exp']): + if (isinstance(exp, dict) and list(exp.keys()) == ['exp']): d['exp'] = exp['exp'] else: d['exp'] = exp - return d \ No newline at end of file + return d 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 9c687dc9..7de36f9e 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_model.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_model.py @@ -19,14 +19,15 @@ from typing import List, Dict, Optional, Union, Type -from jani_generator.jani_entries import JaniValue, JaniVariable, JaniConstant, JaniAutomaton, JaniComposition, JaniProperty, JaniExpression +from jani_generator.jani_entries import JaniValue, JaniVariable, JaniConstant, JaniAutomaton, JaniComposition, \ + JaniProperty, JaniExpression ValidValue = Union[int, float, bool, dict, JaniExpression] class JaniModel: - """This class represents a complete Jani Model, containing all the necessary information to generate a (Plain) Jani file.""" + """Class representing a complete Jani Model, containing all necessary information to generate a plain Jani file.""" def __init__(self): self._name = "" self._type = "mdp" @@ -46,12 +47,14 @@ def get_name(self): def add_jani_variable(self, variable: JaniVariable): self._variables.update({variable.name(): variable}) - def add_variable(self, variable_name: str, variable_type: Type, variable_init_expression: Optional[ValidValue] = None, transient: bool = False): + def add_variable(self, variable_name: str, variable_type: Type, + variable_init_expression: Optional[ValidValue] = None, transient: bool = False): if variable_init_expression is None or isinstance(variable_init_expression, JaniExpression): self.add_jani_variable(JaniVariable(variable_name, variable_type, variable_init_expression, transient)) else: assert JaniValue(variable_init_expression).is_valid(), f"Invalid value for variable {variable_name}" - self.add_jani_variable(JaniVariable(variable_name, variable_type, JaniExpression(variable_init_expression), transient)) + self.add_jani_variable( + JaniVariable(variable_name, variable_type, JaniExpression(variable_init_expression), transient)) def add_jani_constant(self, constant: JaniConstant): self._constants.update({constant.name(): constant}) 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 973e8a0b..940cca28 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_property.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_property.py @@ -24,7 +24,7 @@ class FilterProperty: - """All Property operators must occurr i a FilterProperty object.""" + """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" 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 cdd49dd2..84c6da6c 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_timer.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_timer.py @@ -88,6 +88,7 @@ def make_global_timer_automaton(timers: List[RosTimer], timer.period_int, timer.unit, smallest_unit) for timer in timers } + # TODO: Should be greatest-common-divisor instead global_timer_period = min(timer_periods_in_smallest_unit.values()) global_timer_period_unit = smallest_unit diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_data.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_data.py index d1fee289..d846d899 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_data.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_data.py @@ -14,7 +14,7 @@ # limitations under the License. """ -Module handling ScXML data tags. +Module handling SCXML data tags. """ import re @@ -29,7 +29,7 @@ class ScxmlData: - """Object representing a data tag from a ScXML file. + """Object representing a data tag from a SCXML file. See https://www.w3.org/TR/scxml/#data """ @@ -109,7 +109,7 @@ def _interpret_ecma_script_expr_to_type(self, expr: str) -> type: :return: The type of the data """ my_type = type(interpret_ecma_script_expr(expr)) - if not my_type in get_args(ValidTypes): + if my_type not in get_args(ValidTypes): raise ValueError( f"Type {my_type} must be supported by Jani.") return my_type @@ -145,6 +145,13 @@ def _evalute_possible_types( if 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. diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_event.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_event.py index 815e0f75..bd973a64 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_event.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_event.py @@ -17,37 +17,25 @@ Module to hold scxml even information to convert to jani syncs later. """ -from enum import Enum, auto from typing import Dict, List, Optional from jani_generator.jani_entries.jani_assignment import JaniAssignment from scxml_converter.scxml_converter import ROS_TIMER_RATE_EVENT_PREFIX -class EventSenderType(Enum): - """Enum to differentiate between the different options that events can be sent from.""" - ON_ENTRY = auto() - ON_EXIT = auto() - EDGE = auto() - - class EventSender: - def __init__(self, automaton_name: str, type: EventSenderType, - entity_name: str, assignments: List[JaniAssignment]): + def __init__(self, automaton_name: str, + edge_action_name: str, + assignments: List[JaniAssignment]): """ Initialize the event sender. :param automaton_name: The name of the automaton sending the event. - :param type: The type of the sender. - :param entity_name: The name of the entity sending the event. + :param edge_action_name: The name of the entity sending the event. (location_name for ON_ENTRY and ON_EXIT, edge_action_name for EDGE) """ self.automaton_name = automaton_name - self.type = type - if type == EventSenderType.EDGE: - self.edge_action_name = entity_name - else: - self.location_name = entity_name + self.edge_action_name = edge_action_name self._assignments = assignments def get_assignments(self) -> List[JaniAssignment]: @@ -62,38 +50,18 @@ def __init__(self, automaton_name: str, location_name: str, edge_action_name: st class Event: - def __init__(self, - name: str, - data_struct: Optional[Dict[str, str]] = None): + def __init__(self, + name: str, + data_struct: Optional[Dict[str, type]] = None): self.name = name - self.is_timer_event = False - if self.name.startswith(ROS_TIMER_RATE_EVENT_PREFIX): - self.is_timer_event = True - # If the event is a timer event, there is only a receiver - # It is the edge that the user declared with the - # `ros_rate_callback` tag. It will be handled in the - # `scxml_event_processor` module differently. self.data_struct = data_struct self.senders: List[EventSender] = [] self.receivers: List[EventReceiver] = [] - def add_sender_on_entry(self, automaton_name: str, location_name: str, - assignments: List[JaniAssignment]): - """Add information about the location sending the event on entry.""" - self.senders.append(EventSender( - automaton_name, EventSenderType.ON_ENTRY, location_name, assignments)) - - def add_sender_on_exit(self, automaton_name: str, location_name: str, - assignments: List[JaniAssignment]): - """Add information about the location sending the event on exit.""" - self.senders.append(EventSender( - automaton_name, EventSenderType.ON_EXIT, location_name, assignments)) - def add_sender_edge(self, automaton_name: str, edge_action_name: str, assignments: List[JaniAssignment]): """Add information about the edge sending the event.""" - self.senders.append(EventSender( - automaton_name, EventSenderType.EDGE, edge_action_name, assignments)) + self.senders.append(EventSender(automaton_name, edge_action_name, assignments)) def add_receiver(self, automaton_name: str, location_name: str, edge_action_name: str): """Add information about the edges triggered by the event.""" @@ -108,18 +76,39 @@ def get_receivers(self) -> List[EventReceiver]: """Get the receivers of the event.""" return self.receivers - def get_data_structure(self): + def get_data_structure(self) -> Dict[str, type]: """Get the data structure of the event.""" return self.data_struct - def set_data_structure(self, data_struct: Dict[str, str]): + def set_data_structure(self, data_struct: Dict[str, type]): """Set the data structure of the event.""" self.data_struct = data_struct def is_valid(self): - assert len(self.senders) > 0, f"Event {self.name} must have at least one sender." + """Check if the event is valid.""" + assert len(self.senders) > 0, f"Event {self.name} must have at least one sender." assert len(self.receivers) > 0, f"Event {self.name} must have at least one receiver." + def must_be_skipped(self): + """Indicate whether this must be considered in the conversion.""" + return ( + self.name.startswith(ROS_TIMER_RATE_EVENT_PREFIX) + # If the event is a timer event, there is only a receiver + # It is the edge that the user declared with the + # `ros_rate_callback` tag. It will be handled in the + # `scxml_event_processor` module differently. + or + self._is_bt_event() and len(self.senders) == 0 + ) + + def _is_bt_event(self): + """Check if the event is a behavior tree event. + They may have no sender if the plugin does not implement it.""" + return self.name.startswith("bt_") and ( + self.name.endswith("_running") or + self.name.endswith("_success") or + self.name.endswith("_failure")) + class EventsHolder: """Class to hold all events in the existing automatons.""" 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 da14d8e8..8e0f504b 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 @@ -26,7 +26,7 @@ from jani_generator.jani_entries.jani_expression import JaniExpression from jani_generator.jani_entries.jani_variable import JaniVariable from jani_generator.ros_helpers.ros_timer import RosTimer -from jani_generator.scxml_helpers.scxml_event import (Event, EventSenderType, +from jani_generator.scxml_helpers.scxml_event import (Event, EventsHolder) from scxml_converter.scxml_converter import ROS_TIMER_RATE_EVENT_PREFIX @@ -46,67 +46,20 @@ def implement_scxml_events_as_jani_syncs( for automaton in jani_model.get_automata(): jc.add_element(automaton.get_name()) for event_name, event in events_holder.get_events().items(): - if event.is_timer_event: + if event.must_be_skipped(): continue event.is_valid() event_name_on_send = f"{event_name}_on_send" event_name_on_receive = f"{event_name}_on_receive" - # Expand states if needed + # Check correct action names for sender in event.get_senders(): - automaton = jani_model.get_automaton(sender.automaton_name) - if sender.type == EventSenderType.ON_ENTRY: - additional_loc_name = f"{sender.location_name}_on_entry" - automaton.add_location(additional_loc_name) - for edge in automaton.get_edges(): - for dest in edge.destinations: - if sender.location_name == dest['location']: - dest['location'] = additional_loc_name - event_edge = JaniEdge({ - 'location': additional_loc_name, - 'destinations': [{ - 'location': sender.location_name, - 'probability': {'exp': 1.0}, - 'assignments': sender.get_assignments() - }], - 'action': event_name_on_send - }) - automaton.add_edge(event_edge) - # If the original state was initial, now the on_entry state is initial - if sender.location_name in automaton.get_initial_locations(): - automaton.make_initial(additional_loc_name) - automaton.unset_initial(sender.location_name) - elif sender.type == EventSenderType.ON_EXIT: - raise NotImplementedError( - "For now, we don't know how to handle on_exit events. " + - "Because we would have to check if one of the originally " + - "outgoing edges can fire.") - # TODO: The new edge must only be executed if - # one of the original outgoing edges is taken - # Especially if the original edge fires on an event - # then, where do we consume that? - additional_loc_name = f"{sender.location_name}_on_exit" - automaton.add_location(additional_loc_name) - for edge in automaton.get_edges(): - if sender.location_name == edge.location: - edge.location = additional_loc_name - event_edge = JaniEdge({ - 'location': sender.location_name, - 'destinations': [{ - 'location': additional_loc_name, - 'probability': {'exp': 1.0}, - 'assignments': sender.get_assignments() - }], - 'action': event_name_on_send - }) - automaton.add_edge(event_edge) - else: - # sending event from edge - assert sender.type == EventSenderType.EDGE, \ - "Sender type must be FROM_EDGE." - event_name_on_send = sender.edge_action_name + action_name = sender.edge_action_name + assert action_name == event_name_on_send, \ + f"Action name {action_name} must be {event_name_on_send}." for receivers in event.get_receivers(): action_name = receivers.edge_action_name - assert action_name == event_name_on_receive, f"Action name {action_name} must be {event_name_on_receive}." + assert action_name == event_name_on_receive, \ + f"Action name {action_name} must be {event_name_on_receive}." # Collect the event action names event_action_names.append(event_name_on_send) event_action_names.append(event_name_on_receive) 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 a44d79fa..e552ab83 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py @@ -17,6 +17,8 @@ Module producing jani expressions from ecmascript. """ +from typing import Union + import esprima from jani_generator.jani_entries.jani_expression import JaniExpression @@ -24,7 +26,7 @@ from jani_generator.jani_entries.jani_convince_expression_expansion import BASIC_EXPRESSIONS_MAPPING -def parse_ecmascript_to_jani_expression(ecmascript: str) -> JaniExpression: +def parse_ecmascript_to_jani_expression(ecmascript: str) -> Union[JaniValue, JaniExpression]: """ Parse ecmascript to jani expression. @@ -37,7 +39,8 @@ def parse_ecmascript_to_jani_expression(ecmascript: str) -> JaniExpression: return _parse_ecmascript_to_jani_expression(ast) -def _parse_ecmascript_to_jani_expression(ast: esprima.nodes.Script) -> JaniExpression: +def _parse_ecmascript_to_jani_expression(ast: esprima.nodes.Script + ) -> Union[JaniValue, JaniExpression]: """ Parse ecmascript to jani expression. 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 8a9f14c8..04fcb98d 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py @@ -14,40 +14,49 @@ # limitations under the License. """ -Module defining ScXML tags to match against. +Module defining SCXML tags to match against. """ import xml.etree.ElementTree as ET from hashlib import sha256 -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import List, Optional, Tuple, Union -from mc_toolchain_jani_common.common import remove_namespace -from mc_toolchain_jani_common.ecmascript_interpretation import interpret_ecma_script_expr -from jani_generator.jani_entries import JaniModel +# TODO: Improve imports from jani_generator.jani_entries.jani_assignment import JaniAssignment 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 import JaniExpression +from jani_generator.jani_entries.jani_expression_generator import ( + and_operator, not_operator) from jani_generator.jani_entries.jani_guard import JaniGuard -from jani_generator.jani_entries.jani_variable import JaniVariable from jani_generator.scxml_helpers.scxml_data import ScxmlData from jani_generator.scxml_helpers.scxml_event import Event, EventsHolder from jani_generator.scxml_helpers.scxml_expression import \ parse_ecmascript_to_jani_expression +from mc_toolchain_jani_common.common import remove_namespace +from mc_toolchain_jani_common.ecmascript_interpretation import \ + interpret_ecma_script_expr +from scxml_converter.scxml_entries import (ScxmlAssign, ScxmlBase, + ScxmlExecutionBody, ScxmlIf, + ScxmlRoot, ScxmlSend) # The type to be exctended by parsing the scxml file ModelTupleType = Tuple[JaniAutomaton, EventsHolder] -def _hash_element(element: ET.Element) -> str: +def _hash_element(element: Union[ET.Element, ScxmlBase]) -> str: """ Hash an ElementTree element. :param element: The element to hash. :return: The hash of the element. """ - s = ET.tostring(element, encoding='unicode', method='xml') - return sha256(s.encode()).hexdigest()[:32] + if isinstance(element, ET.Element): + s = ET.tostring(element, encoding='unicode', method='xml') + elif isinstance(element, ScxmlBase): + s = ET.tostring(element.as_xml(), encoding='unicode', method='xml') + else: + raise ValueError(f"Element type {type(element)} not supported.") + return sha256(s.encode()).hexdigest()[:8] def _get_state_name(element: ET.Element) -> str: @@ -60,27 +69,48 @@ def _get_state_name(element: ET.Element) -> str: raise NotImplementedError('Only states with an id are supported.') -def _interpret_scxml_executable_content(element: ET.Element) -> Union[ - JaniAssignment, - JaniExpression -]: - """Interpret the executable content of an SCXML element. +def _interpret_scxml_assign( + elem: ScxmlAssign, event_substitution: Optional[str] = None) -> JaniAssignment: + """Interpret SCXML assign element. :param element: The SCXML element to interpret. :return: The action or expression to be executed. """ - if remove_namespace(element.tag) == 'assign': - return JaniAssignment({ - "ref": element.attrib['location'], - "value": parse_ecmascript_to_jani_expression(element.attrib['expr']) - }) + assert isinstance(elem, ScxmlAssign), \ + f"Expected ScxmlAssign, got {type(elem)}" + 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 + }) + + +def _merge_conditions( + previous_conditions: List[JaniExpression], + new_condition: Optional[JaniExpression] = None) -> JaniExpression: + """This merges negated conditions of previous if-clauses with the condition of the current + if-clause. This is necessary to properly implement the if-else semantics of SCXML by parallel + outgoing transitions in Jani. + + :param previous_conditions: The conditions of the previous if-clauses. (not yet negated) + :param new_condition: The condition of the current if-clause. + :return: The merged condition. + """ + if new_condition is not None: + joint_condition = new_condition else: - raise NotImplementedError( - f'Element {remove_namespace(element.tag)} not implemented') + joint_condition = JaniExpression(True) + for pc in previous_conditions: + negated_pc = not_operator(pc) + joint_condition = and_operator(joint_condition, negated_pc) + return joint_condition class BaseTag: - """Base class for all ScXML tags.""" + """Base class for all SCXML tags.""" # class function to initialize the correct tag object @staticmethod def from_element(element: ET.Element, @@ -93,7 +123,7 @@ def from_element(element: ET.Element, """ tag = remove_namespace(element.tag) if tag not in CLASS_BY_TAG: - raise NotImplementedError(f"Tag {tag} not implemented.") + raise NotImplementedError(f"Tag >{tag}< not implemented.") return CLASS_BY_TAG[tag](element, call_trace, model) def __init__(self, element: ET.Element, @@ -131,21 +161,21 @@ def write_model(self): class Assign(BaseTag): - """Object representing an assign tag from a ScXML file. + """Object representing an assign tag from a SCXML file. See https://www.w3.org/TR/scxml/#assign """ class DatamodelTag(BaseTag): - """Object representing a datamodel tag from a ScXML file. + """Object representing a datamodel tag from a SCXML file. See https://www.w3.org/TR/scxml/#datamodel """ class DataTag(BaseTag): - """Object representing a data tag from a ScXML file. + """Object representing a data tag from a SCXML file. See https://www.w3.org/TR/scxml/#data """ @@ -158,29 +188,50 @@ def write_model(self): "Children of the data tag are currently not supported.") -class LogTag(BaseTag): - """Object representing a log tag from a ScXML file. +class ElseTag(BaseTag): + """Object representing an else tag from a SCXML file. - Currently, this tag is not ignored. + No implementation needed, because the children are already processed. + """ + + +class ElseIfTag(BaseTag): + """Object representing an elseif tag from a SCXML file. + + No implementation needed, because the children are already processed. + """ + + +class IfTag(BaseTag): + """Object representing an if tag from a SCXML file. + + No implementation needed, because the children are already processed. """ class OnEntryTag(BaseTag): - """Object representing an onentry tag from a ScXML file. + """Object representing an onentry tag from a SCXML file. + + No implementation needed, because the children are already processed. + """ + + +class OnExitTag(BaseTag): + """Object representing an onexid tag from a SCXML file. No implementation needed, because the children are already processed. """ class ParamTag(BaseTag): - """Object representing a param tag from a ScXML file. + """Object representing a param tag from a SCXML file. No implementation needed, because the children are already processed. """ class ScxmlTag(BaseTag): - """Object representing a generic ScXML tag.""" + """Object representing the root SCXML tag.""" def write_model(self): if 'name' in self.element.attrib: @@ -189,139 +240,222 @@ def write_model(self): p_name = _hash_element(self.element) self.automaton.set_name(p_name) super().write_model() + # Note: we don't support the initial tag (as state) https://www.w3.org/TR/scxml/#initial if 'initial' in self.element.attrib: self.automaton.make_initial(self.element.attrib['initial']) class SendTag(BaseTag): - """Object representing a send tag from a ScXML file. + """Object representing a send tag from a SCXML file. See https://www.w3.org/TR/scxml/#send """ - def write_model(self): - event_name = self.element.attrib["event"] - params = {} - for child in self.element: - if remove_namespace(child.tag) == 'param': - expr = child.attrib['expr'] - variables = {} - for n, v in self.automaton.get_variables().items(): - variables[n] = v.get_type()() - obj = interpret_ecma_script_expr(expr, variables) - p_type = type(obj) - p_name = child.attrib['name'] - params[p_name] = {} - params[p_name]['type'] = p_type - params[p_name]['jani_expr'] = parse_ecmascript_to_jani_expression( - expr) - assignments = [] - for p_name, param in params.items(): - assignments.append(JaniAssignment({ - "ref": f'{event_name}.{p_name}', - "value": param['jani_expr'], - "index": 0 - })) - # Additional flag to signal the value from the event is now valid - assignments.append(JaniAssignment({ - "ref": f'{event_name}.valid', - "value": True, - "index": 1 - })) - data_struct = {name: params[name]['type'] for name in params} - if not self.events_holder.has_event(event_name): - new_event = Event( - event_name, - data_struct=data_struct - ) - self.events_holder.add_event(new_event) - existing_event = self.events_holder.get_event(event_name) - existing_event.data_struct = data_struct - if remove_namespace(self.call_trace[-1].tag) == 'onentry': - entity_name = _get_state_name(self.call_trace[-2]) - existing_event.add_sender_on_entry( - self.automaton.get_name(), entity_name, assignments) - elif remove_namespace(self.call_trace[-1].tag) == 'onexit': - entity_name = _get_state_name(self.call_trace[-2]) - existing_event.add_sender_on_exit( - self.automaton.get_name(), entity_name, assignments) - elif remove_namespace(self.call_trace[-1].tag) == 'transition': - transition = self.call_trace[-1] - assert 'transition' in transition.tag, \ - f"Expected transition, got {transition.tag}" - action_name = transition.attrib['event'] + "_on_send" - existing_event.add_sender_edge( - self.automaton.get_name(), action_name, assignments) - else: - raise RuntimeError( - 'Unknown place for send element: ' + - f'{remove_namespace(self.call_trace[-1].tag)}') - class TransitionTag(BaseTag): - """Object representing a transition tag from a ScXML file. + """Object representing a transition tag from a SCXML file. See https://www.w3.org/TR/scxml/#transition """ + def interpret_scxml_executable_content_body( + self, + body: ScxmlExecutionBody, + source: str, + target: str, + hash_str: str, + guard: Optional[JaniGuard] = None, + trigger_event_action: Optional[str] = None + ) -> List[JaniEdge]: + """Interpret a body of executable content of an SCXML element. + + :param body: The body of the SCXML element to interpret. + :return: The edges that contain the actions and expressions to be executed. + """ + edge_action_name = f"{source}-{target}-{hash_str}" + new_edges = [] + new_locations = [] + # First edge. Has to evaluate guard and trigger event of original transition. + new_edges.append(JaniEdge({ + "location": source, + "action": (trigger_event_action + if trigger_event_action is not None else edge_action_name), + "guard": guard.expression if guard is not None else None, + "destinations": [{ + "location": None, + "assignments": [] + }] + })) + for i, ec in enumerate(body): + if isinstance(ec, ScxmlAssign): + jani_assignment = _interpret_scxml_assign(ec, self._trans_event_name) + new_edges[-1].destinations[0]['assignments'].append(jani_assignment) + elif isinstance(ec, ScxmlSend): + event_name = ec.get_event() + event_send_action_name = event_name + "_on_send" + interm_loc = f'{source}-{i}-{hash_str}' + new_edges[-1].destinations[0]['location'] = interm_loc + new_edge = JaniEdge({ + "location": interm_loc, + "action": event_send_action_name, + "guard": None, + "destinations": [{ + "location": None, + "assignments": [] + }] + }) + data_structure_for_event = {} + for param in ec.get_params(): + expr = param.get_expr() if param.get_expr() is not None else param.get_location() + new_edge.destinations[0]['assignments'].append(JaniAssignment({ + "ref": f'{ec.get_event()}.{param.get_name()}', + "value": parse_ecmascript_to_jani_expression( + expr).replace_event(self._trans_event_name) + })) + variables = {} + for n, v in self.automaton.get_variables().items(): + variables[n] = v.get_type()() + data_structure_for_event[param.get_name()] = \ + type(interpret_ecma_script_expr(expr, variables)) + new_edge.destinations[0]['assignments'].append(JaniAssignment({ + "ref": f'{ec.get_event()}.valid', + "value": True + })) + + if not self.events_holder.has_event(event_name): + send_event = Event( + event_name, + data_structure_for_event + ) + self.events_holder.add_event(send_event) + else: + send_event = self.events_holder.get_event(event_name) + send_event.set_data_structure( + data_structure_for_event + ) + send_event.add_sender_edge( + self.automaton.get_name(), event_send_action_name, []) + + 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" + new_edges[-1].destinations[0]['location'] = interm_loc_before + previous_conditions = [] + for cond_str, conditional_body in ec.get_conditional_executions(): + print(f"Condition: {cond_str}") + print(f"Body: {conditional_body}") + current_cond = parse_ecmascript_to_jani_expression(cond_str) + jani_cond = _merge_conditions( + previous_conditions, current_cond).replace_event(self._trans_event_name) + sub_edges, sub_locs = self.interpret_scxml_executable_content_body( + conditional_body, interm_loc_before, interm_loc_after, + '-'.join([hash_str, _hash_element(ec), cond_str]), + JaniGuard(jani_cond), None) + new_edges.extend(sub_edges) + new_locations.extend(sub_locs) + previous_conditions.append(current_cond) + # Add else branch: + if ec.get_else_execution() is not None: + print(f"Else: {ec.get_else_execution()}") + jani_cond = _merge_conditions( + previous_conditions).replace_event(self._trans_event_name) + sub_edges, sub_locs = self.interpret_scxml_executable_content_body( + ec.get_else_execution(), interm_loc_before, interm_loc_after, + '-'.join([hash_str, _hash_element(ec), 'else']), + JaniGuard(jani_cond), None) + new_edges.extend(sub_edges) + new_locations.extend(sub_locs) + new_edges.append(JaniEdge({ + "location": interm_loc_after, + "action": edge_action_name, + "guard": None, + "destinations": [{ + "location": None, + "assignments": [] + }] + })) + new_locations.append(interm_loc_before) + new_locations.append(interm_loc_after) + new_edges[-1].destinations[0]['location'] = target + return new_edges, new_locations + def write_model(self): parent_name = _get_state_name(self.call_trace[-1]) action_name = None + self._trans_event_name = None if 'event' in self.element.attrib: - event_name = self.element.attrib['event'] - action_name = event_name + "_on_receive" - if not self.events_holder.has_event(event_name): + self._trans_event_name = self.element.attrib['event'] + assert len(self._trans_event_name) > 0, "Empty event name not supported." + assert " " not in self._trans_event_name, "Multiple events not supported." + action_name = self._trans_event_name + "_on_receive" + if not self.events_holder.has_event(self._trans_event_name): new_event = Event( - event_name + self._trans_event_name # we can't know the data structure here ) self.events_holder.add_event(new_event) - existing_event = self.events_holder.get_event(event_name) + existing_event = self.events_holder.get_event(self._trans_event_name) existing_event.add_receiver( self.automaton.get_name(), parent_name, action_name) if 'target' in self.element.attrib: target = self.element.attrib['target'] else: - target = None + raise RuntimeError('Target attribute is mandatory.') if 'cond' in self.element.attrib: expression = parse_ecmascript_to_jani_expression( - self.element.attrib['cond']) + self.element.attrib['cond']) if 'event' in self.element.attrib: - expression.replace_event(event_name) + expression.replace_event(self._trans_event_name) guard = JaniGuard( expression ) else: guard = None - assignments = [] + original_transition_body = [] + + root = self.call_trace[0] + for child in root.iter(): + child.tag = remove_namespace(child.tag) + for child in self.element: - if remove_namespace(child.tag) != 'send': - child = _interpret_scxml_executable_content(child) - if isinstance(child, JaniAssignment): - if 'event' in self.element.attrib: - child._value.replace_event(event_name) - assignments.append(child) - else: - raise NotImplementedError( - f'Element {remove_namespace(child.tag)} not implemented') - else: # send tag - assert action_name is None, \ - "Transistions can only either send or receive events, not both." - action_name = child.attrib['event'] + "_on_send" - self.automaton.add_edge(JaniEdge({ - "location": parent_name, - "action": action_name, - "guard": guard, - "destinations": [{ - "location": target, - "assignments": assignments - }] - })) - super().write_model() + if remove_namespace(child.tag) == 'send': + original_transition_body.append(ScxmlSend.from_xml_tree(child)) + elif remove_namespace(child.tag) == 'if': + original_transition_body.append(ScxmlIf.from_xml_tree(child)) + elif remove_namespace(child.tag) == 'assign': + original_transition_body.append(ScxmlAssign.from_xml_tree(child)) + else: + raise ValueError( + f"Tag {remove_namespace(child.tag)} not supported.") + + # TODO, the children should also contain onexit of the source state + # and onentry of the target state + root_scxml = ScxmlRoot.from_xml_tree(root) + source_state = root_scxml.get_state_by_id(parent_name) + assert source_state is not None, f"Source state {parent_name} not found." + target_state = root_scxml.get_state_by_id(target) + assert target_state is not None, f"Target state {target} not found." + + transition_body = [] + if source_state.get_onexit() is not None: + transition_body.extend(source_state.get_onexit()) + transition_body.extend(original_transition_body) + if target_state.get_onentry() is not None: + transition_body.extend(target_state.get_onentry()) + + hash_str = _hash_element(self.element) + new_edges, new_locations = self.interpret_scxml_executable_content_body( + transition_body, parent_name, target, hash_str, guard, action_name) + for edge in new_edges: + self.automaton.add_edge(edge) + for loc in new_locations: + self.automaton.add_location(loc) class StateTag(BaseTag): - """Object representing a state tag from a ScXML file. + """Object representing a state tag from a SCXML file. See https://www.w3.org/TR/scxml/#state """ @@ -329,6 +463,7 @@ class StateTag(BaseTag): def write_model(self): p_name = _get_state_name(self.element) self.automaton.add_location(p_name) + # TODO: Make sure initial states that have onentry execute the onentry block at start super().write_model() @@ -336,7 +471,10 @@ def write_model(self): 'assign': Assign, 'data': DataTag, 'datamodel': DatamodelTag, - 'log': LogTag, + 'else': ElseTag, + 'elseif': ElseIfTag, + 'if': IfTag, + 'onexit': OnExitTag, 'onentry': OnEntryTag, 'param': ParamTag, 'scxml': ScxmlTag, 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 a16bf5ea..da734f29 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 @@ -20,16 +20,18 @@ import json import os import xml.etree.ElementTree as ET -from typing import List +from typing import List, Optional -from mc_toolchain_jani_common.common import remove_namespace from jani_generator.jani_entries.jani_automaton import JaniAutomaton from jani_generator.jani_entries.jani_model import JaniModel -from jani_generator.ros_helpers.ros_timer import RosTimer, make_global_timer_automaton +from jani_generator.ros_helpers.ros_timer import (RosTimer, + make_global_timer_automaton) from jani_generator.scxml_helpers.scxml_event import EventsHolder from jani_generator.scxml_helpers.scxml_event_processor import \ implement_scxml_events_as_jani_syncs from jani_generator.scxml_helpers.scxml_tags import BaseTag +from mc_toolchain_jani_common.common import remove_namespace +from scxml_converter.bt_converter import bt_converter from scxml_converter.scxml_converter import ros_to_scxml_converter @@ -45,6 +47,8 @@ def convert_scxml_element_to_jani_automaton( """ assert remove_namespace(element.tag) == "scxml", \ "The element must be the root scxml tag of the file." + for child in element.iter(): + child.tag = remove_namespace(child.tag) BaseTag.from_element(element, [], (jani_automaton, events_holder)).write_model() @@ -53,7 +57,7 @@ def convert_multiple_scxmls_to_jani( scxmls: List[str], timers: List[RosTimer], max_time_ns: int - ) -> JaniModel: +) -> JaniModel: """ Assemble automata from multiple SCXML files into a Jani model. @@ -100,6 +104,7 @@ def _parse_time_element(time_element: ET.Element) -> int: return int(time_element.attrib["value"]) * TIME_MULTIPLIERS[time_unit] +# TODO: Move this - this is XML (not SCXML) def interpret_top_level_xml(xml_path: str) -> JaniModel: """ Interpret the top-level XML file as a Jani model. @@ -113,9 +118,12 @@ def interpret_top_level_xml(xml_path: str) -> JaniModel: assert remove_namespace(xml.getroot().tag) == "convince_mc_tc", \ "The top-level XML element must be convince_mc_tc." - for main_point in xml.getroot(): - if remove_namespace(main_point.tag) == "mc_parameters": - for mc_parameter in main_point: + scxml_files_to_convert = [] + bt: Optional[str] = None # The path to the Behavior Tree definition + + for first_level in xml.getroot(): + if remove_namespace(first_level.tag) == "mc_parameters": + for mc_parameter in first_level: # if remove_namespace(mc_parameter.tag) == "time_resolution": # time_resolution = _parse_time_element(mc_parameter) if remove_namespace(mc_parameter.tag) == "max_time": @@ -123,29 +131,54 @@ def interpret_top_level_xml(xml_path: str) -> JaniModel: else: raise ValueError( f"Invalid mc_parameter tag: {mc_parameter.tag}") - elif remove_namespace(main_point.tag) == "node_models": - node_model_fnames = [] - for node_model in main_point: + elif remove_namespace(first_level.tag) == "behavior_tree": + plugins = [] + for child in first_level: + if remove_namespace(child.tag) == "input": + if child.attrib["type"] == "bt.cpp-xml": + assert bt is None, "Only one BT is supported." + bt = child.attrib["src"] + elif child.attrib["type"] == "bt-plugin-ros-scxml": + plugins.append(child.attrib["src"]) + else: + raise ValueError( + f"Invalid input tag type: {child.attrib['type']}") + else: + raise ValueError( + f"Invalid behavior_tree tag: {child.tag}") + assert bt is not None, "There must be a Behavior Tree defined." + elif remove_namespace(first_level.tag) == "node_models": + for node_model in first_level: assert remove_namespace(node_model.tag) == "input", \ "Only input tags are supported." assert node_model.attrib['type'] == "ros-scxml", \ "Only ROS-SCXML node models are supported." - node_model_fnames.append(node_model.attrib["src"]) - elif remove_namespace(main_point.tag) == "properties": + scxml_files_to_convert.append( + os.path.join(folder_of_xml, node_model.attrib["src"])) + elif remove_namespace(first_level.tag) == "properties": properties = [] - for property in main_point: + for property in first_level: assert remove_namespace(property.tag) == "input", \ "Only input tags are supported." assert property.attrib['type'] == "jani", \ "Only Jani properties are supported." properties.append(property.attrib["src"]) else: - raise ValueError(f"Invalid main point tag: {main_point.tag}") + raise ValueError(f"Invalid main point tag: {first_level.tag}") + + # Preprocess behavior tree and plugins + if bt is not None: + bt_path = os.path.join(folder_of_xml, bt) + plugin_paths = [] + for plugin in plugins: + plugin_paths.append(os.path.join(folder_of_xml, plugin)) + output_folder = folder_of_xml # TODO: Think about better folder structure + scxml_files = bt_converter(bt_path, plugin_paths, output_folder) + scxml_files_to_convert.extend(scxml_files) plain_scxml_models = [] all_timers = [] # type: List[RosTimer] - for node_model_fname in node_model_fnames: - fname = os.path.join(folder_of_xml, node_model_fname) + for fname in scxml_files_to_convert: with open(fname, 'r', encoding='utf-8') as f: model, timers = ros_to_scxml_converter(f.read()) for timer_name, timer_rate in timers: diff --git a/jani_generator/test/_test_data/battery_example/output.jani b/jani_generator/test/_test_data/battery_example/output_GROUND_TRUTH.jani similarity index 91% rename from jani_generator/test/_test_data/battery_example/output.jani rename to jani_generator/test/_test_data/battery_example/output_GROUND_TRUTH.jani index d12d73dd..7024eb58 100644 --- a/jani_generator/test/_test_data/battery_example/output.jani +++ b/jani_generator/test/_test_data/battery_example/output_GROUND_TRUTH.jani @@ -21,14 +21,14 @@ ], "constants": [], "actions": [ - { - "name": "BatteryDrainer_action_0" - }, { "name": "level_on_receive" }, { "name": "level_on_send" + }, + { + "name": "use_battery-use_battery-683f44d4" } ], "automata": [ @@ -39,18 +39,18 @@ "name": "use_battery" }, { - "name": "use_battery_on_entry" + "name": "use_battery-1-683f44d4" } ], "initial-locations": [ - "use_battery_on_entry" + "use_battery" ], "edges": [ { "location": "use_battery", "destinations": [ { - "location": "use_battery_on_entry", + "location": "use_battery-1-683f44d4", "assignments": [ { "ref": "battery_percent", @@ -64,16 +64,13 @@ ] } ], - "action": "BatteryDrainer_action_0" + "action": "use_battery-use_battery-683f44d4" }, { - "location": "use_battery_on_entry", + "location": "use_battery-1-683f44d4", "destinations": [ { "location": "use_battery", - "probability": { - "exp": 1.0 - }, "assignments": [ { "ref": "level.data", @@ -83,7 +80,7 @@ { "ref": "level.valid", "value": true, - "index": 1 + "index": 0 } ] } @@ -227,9 +224,9 @@ ] }, { - "result": "BatteryDrainer_action_0", + "result": "use_battery-use_battery-683f44d4", "synchronise": [ - "BatteryDrainer_action_0", + "use_battery-use_battery-683f44d4", null, null ] diff --git a/jani_generator/test/_test_data/ros_example/.gitignore b/jani_generator/test/_test_data/ros_example/.gitignore index 4ec5fcab..172d4be9 100644 --- a/jani_generator/test/_test_data/ros_example/.gitignore +++ b/jani_generator/test/_test_data/ros_example/.gitignore @@ -1,3 +1,4 @@ *_plain.scxml *_plain_timer_*.scxml -global_time_statemachine.scxml \ No newline at end of file +global_time_statemachine.scxml\ +main.jani \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_example/battery_drainer.scxml b/jani_generator/test/_test_data/ros_example/battery_drainer.scxml index 0bc5bc66..9b99fa22 100644 --- a/jani_generator/test/_test_data/ros_example/battery_drainer.scxml +++ b/jani_generator/test/_test_data/ros_example/battery_drainer.scxml @@ -16,15 +16,15 @@ - + - + - + --> diff --git a/jani_generator/test/_test_data/ros_example/battery_manager.scxml b/jani_generator/test/_test_data/ros_example/battery_manager.scxml index 9543da4d..c285245f 100644 --- a/jani_generator/test/_test_data/ros_example/battery_manager.scxml +++ b/jani_generator/test/_test_data/ros_example/battery_manager.scxml @@ -14,13 +14,13 @@ - + - + diff --git a/jani_generator/test/_test_data/ros_example_w_bt/.gitignore b/jani_generator/test/_test_data/ros_example_w_bt/.gitignore new file mode 100644 index 00000000..d09b91c8 --- /dev/null +++ b/jani_generator/test/_test_data/ros_example_w_bt/.gitignore @@ -0,0 +1,6 @@ +*_plain.scxml +*_plain_timer_*.scxml +*_TopicAction.scxml +*_TopicCondition.scxml +bt.scxml +main.jani \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_example_w_bt/battery_depleted.jani b/jani_generator/test/_test_data/ros_example_w_bt/battery_depleted.jani new file mode 100644 index 00000000..c43126c8 --- /dev/null +++ b/jani_generator/test/_test_data/ros_example_w_bt/battery_depleted.jani @@ -0,0 +1,78 @@ +{ + "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/scxml_converter/test/_test_data/battery_drainer_charge/battery_drainer.scxml b/jani_generator/test/_test_data/ros_example_w_bt/battery_drainer.scxml similarity index 74% rename from scxml_converter/test/_test_data/battery_drainer_charge/battery_drainer.scxml rename to jani_generator/test/_test_data/ros_example_w_bt/battery_drainer.scxml index cc3c90c3..ed8acf51 100644 --- a/scxml_converter/test/_test_data/battery_drainer_charge/battery_drainer.scxml +++ b/jani_generator/test/_test_data/ros_example_w_bt/battery_drainer.scxml @@ -16,15 +16,15 @@ - + - + - + - + - + diff --git a/jani_generator/test/_test_data/ros_example_w_bt/battery_manager.scxml b/jani_generator/test/_test_data/ros_example_w_bt/battery_manager.scxml new file mode 100644 index 00000000..668ab31e --- /dev/null +++ b/jani_generator/test/_test_data/ros_example_w_bt/battery_manager.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/_test_data/ros_example_w_bt/bt.xml b/jani_generator/test/_test_data/ros_example_w_bt/bt.xml new file mode 100644 index 00000000..5d5d2474 --- /dev/null +++ b/jani_generator/test/_test_data/ros_example_w_bt/bt.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/battery_drainer_charge/bt_topic_action.scxml b/jani_generator/test/_test_data/ros_example_w_bt/bt_topic_action.scxml similarity index 75% rename from scxml_converter/test/_test_data/battery_drainer_charge/bt_topic_action.scxml rename to jani_generator/test/_test_data/ros_example_w_bt/bt_topic_action.scxml index 3e32098d..ca15edbc 100644 --- a/scxml_converter/test/_test_data/battery_drainer_charge/bt_topic_action.scxml +++ b/jani_generator/test/_test_data/ros_example_w_bt/bt_topic_action.scxml @@ -2,7 +2,7 @@ @@ -13,7 +13,9 @@ - + + + diff --git a/jani_generator/test/_test_data/ros_example_w_bt/bt_topic_condition.scxml b/jani_generator/test/_test_data/ros_example_w_bt/bt_topic_condition.scxml new file mode 100644 index 00000000..7173cf23 --- /dev/null +++ b/jani_generator/test/_test_data/ros_example_w_bt/bt_topic_condition.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/_test_data/ros_example_w_bt/main.xml b/jani_generator/test/_test_data/ros_example_w_bt/main.xml new file mode 100644 index 00000000..4a72bb9a --- /dev/null +++ b/jani_generator/test/_test_data/ros_example_w_bt/main.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_example_w_bt/main_failing_prop.xml b/jani_generator/test/_test_data/ros_example_w_bt/main_failing_prop.xml new file mode 100644 index 00000000..356eaeed --- /dev/null +++ b/jani_generator/test/_test_data/ros_example_w_bt/main_failing_prop.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jani_generator/test/test_systemtest_scxml_to_jani.py b/jani_generator/test/test_systemtest_scxml_to_jani.py index f37f03e5..f276eb9d 100644 --- a/jani_generator/test/test_systemtest_scxml_to_jani.py +++ b/jani_generator/test/test_systemtest_scxml_to_jani.py @@ -28,7 +28,7 @@ from jani_generator.scxml_helpers.scxml_to_jani import ( convert_multiple_scxmls_to_jani, convert_scxml_element_to_jani_automaton, interpret_top_level_xml) -from .test_utilities_smc_strom import run_smc_storm_with_output +from .test_utilities_smc_storm import run_smc_storm_with_output class TestConversion(unittest.TestCase): @@ -37,12 +37,12 @@ def test_basic_example(self): Very basic example of a SCXML file. """ basic_scxml = """ - - + """ @@ -73,12 +73,12 @@ def test_battery_drainer(self): automaton = jani_a.as_dict(constant={}) self.assertEqual(automaton["name"], "BatteryDrainer") - self.assertEqual(len(automaton["locations"]), 1) + self.assertEqual(len(automaton["locations"]), 2) self.assertEqual(len(automaton["initial-locations"]), 1) init_location = automaton["locations"][0] self.assertEqual(init_location['name'], automaton.get("initial-locations")[0]) - self.assertEqual(len(automaton["edges"]), 1) + self.assertEqual(len(automaton["edges"]), 2) # Variables self.assertEqual(len(automaton["variables"]), 1) @@ -117,17 +117,20 @@ def test_battery_manager(self): self.assertEqual(variable["type"], "bool") self.assertEqual(variable["initial-value"], False) - @pytest.mark.skip(reason="WIP") def test_example_with_sync(self): """ Testing the conversion of two SCXML files with a sync. """ - scxml_battery_drainer = os.path.join( - os.path.dirname(__file__), '_test_data', 'battery_example', - 'battery_drainer.scxml') - scxml_battery_manager = os.path.join( - os.path.dirname(__file__), '_test_data', 'battery_example', - 'battery_manager.scxml') + TEST_DATA_FOLDER = os.path.join( + os.path.dirname(__file__), '_test_data', 'battery_example') + scxml_battery_drainer_path = os.path.join( + TEST_DATA_FOLDER, 'battery_drainer.scxml') + scxml_battery_manager_path = os.path.join( + TEST_DATA_FOLDER, 'battery_manager.scxml') + with open(scxml_battery_drainer_path, 'r', encoding='utf-8') as f: + scxml_battery_drainer = f.read() + 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, @@ -138,6 +141,7 @@ def test_example_with_sync(self): jani_dict = jani_model.as_dict() # pprint(jani_dict) + # Check automata self.assertEqual(len(jani_dict["automata"]), 3) names = [a["name"] for a in jani_dict["automata"]] self.assertIn("BatteryDrainer", names) @@ -160,10 +164,6 @@ def test_example_with_sync(self): 'synchronise': [ None, 'level_on_receive', 'level_on_receive']}, syncs) - self.assertIn({'result': 'BatteryDrainer_action_0', - 'synchronise': [ - 'BatteryDrainer_action_0', None, None]}, - syncs) # Check global variables for event variables = jani_dict["variables"] @@ -178,10 +178,10 @@ def test_example_with_sync(self): "transient": False}, variables) # Check full jani file - TEST_FILE = 'test_output.jani' + TEST_FILE = os.path.join( + TEST_DATA_FOLDER, 'output.jani') GROUND_TRUTH_FILE = os.path.join( - os.path.dirname(__file__), '_test_data', - 'battery_example', 'output.jani') + TEST_DATA_FOLDER, 'output_GROUND_TRUTH.jani') if os.path.exists(TEST_FILE): os.remove(TEST_FILE) with open(TEST_FILE, "w", encoding='utf-8') as output_file: @@ -195,26 +195,25 @@ def test_example_with_sync(self): if os.path.exists(TEST_FILE): os.remove(TEST_FILE) - def _test_with_entrypoint(self, main_xml: str, success: bool): + def _test_with_entrypoint(self, main_xml: str, folder: str, property_name: str, success: bool): """Testing the conversion of the main.xml file with the entrypoint.""" test_data_dir = os.path.join( - os.path.dirname(__file__), '_test_data', 'ros_example') + os.path.dirname(__file__), '_test_data', folder) xml_main_path = os.path.join(test_data_dir, main_xml) ouput_path = os.path.join(test_data_dir, 'main.jani') if os.path.exists(ouput_path): os.remove(ouput_path) interpret_top_level_xml(xml_main_path) self.assertTrue(os.path.exists(ouput_path)) - ground_truth = os.path.join( - test_data_dir, - 'jani_model_GROUND_TRUTH.jani') - with open(ouput_path, "r", encoding='utf-8') as f: - jani_dict = json.load(f) - with open(ground_truth, "r", encoding='utf-8') as f: - ground_truth = json.load(f) - self.maxDiff = None + # ground_truth = os.path.join( + # test_data_dir, + # 'jani_model_GROUND_TRUTH.jani') + # with open(ouput_path, "r", encoding='utf-8') as f: + # jani_dict = json.load(f) + # with open(ground_truth, "r", encoding='utf-8') as f: + # ground_truth = json.load(f) + # self.maxDiff = None # self.assertEqual(jani_dict, ground_truth) - property_name = "battery_depleted" pos_res = "Result: 1" if success else "Result: 0" neg_res = "Result: 0" if success else "Result: 1" run_smc_storm_with_output( @@ -223,18 +222,36 @@ def _test_with_entrypoint(self, main_xml: str, success: bool): ouput_path, pos_res], [neg_res]) - if os.path.exists(ouput_path): - os.remove(ouput_path) + # if os.path.exists(ouput_path): + # os.remove(ouput_path) def test_with_entrypoint_main_success(self): """Test the main.xml file with the entrypoint. Here we expect the property to be satisfied.""" - self._test_with_entrypoint('main.xml', True) + self._test_with_entrypoint('main.xml', 'ros_example', 'battery_depleted', True) def test_with_entrypoint_main_fail(self): """Test the main_failing.xml file with the entrypoint. Here we expect the property to be *not* satisfied.""" - self._test_with_entrypoint('main_failing_prop.xml', False) + self._test_with_entrypoint( + 'main_failing_prop.xml', 'ros_example', 'battery_depleted', False) + + def test_with_entrypoint_w_bt_main_battery_depleted(self): + """Test the main.xml file with the entrypoint. + Here we expect the property to be satisfied.""" + # TODO: Improve properties under evaluation! + self._test_with_entrypoint('main.xml', 'ros_example_w_bt', 'battery_depleted', False) + + def test_with_entrypoint_w_bt_main_battery_under_twenty(self): + """Test the main.xml file with the entrypoint. + Here we expect the property to be satisfied.""" + # TODO: Improve properties under evaluation! + self._test_with_entrypoint('main.xml', 'ros_example_w_bt', 'battery_below_20', False) + + def test_with_entrypoint_w_bt_main_alarm_and_charge(self): + """Test the main_failing.xml file with the entrypoint. + Here we expect the property to be *not* satisfied.""" + self._test_with_entrypoint('main.xml', 'ros_example_w_bt', 'battery_alarm_on', True) if __name__ == '__main__': diff --git a/jani_generator/test/test_utilities_smc_strom.py b/jani_generator/test/test_utilities_smc_storm.py similarity index 96% rename from jani_generator/test/test_utilities_smc_strom.py rename to jani_generator/test/test_utilities_smc_storm.py index 81b237aa..e4d2861a 100644 --- a/jani_generator/test/test_utilities_smc_strom.py +++ b/jani_generator/test/test_utilities_smc_storm.py @@ -34,7 +34,7 @@ def _interpret_output( def _run_smc_storm(args: str) -> Tuple[str, str, int]: - command = f"smc_storm {args}" + command = f"smc_storm {args} --max-trace-length 10000 --max-n-traces 10000" print("Running command: ", command) process = subprocess.Popen( command, diff --git a/mc_toolchain_jani_common/test/test_utilities_smc_strom.py b/mc_toolchain_jani_common/test/test_utilities_smc_strom.py new file mode 100644 index 00000000..6bc794fa --- /dev/null +++ b/mc_toolchain_jani_common/test/test_utilities_smc_strom.py @@ -0,0 +1,51 @@ +# 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. + +import subprocess + +import pytest + + +def _run_smc_storm(args: str): + command = f"smc_storm {args}" + print("Running command: ", command) + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True, + universal_newlines=True + ) + stdout, stderr = process.communicate() + return_code = process.returncode + print("smc_storm stdout:") + print(stdout) + print("smc_storm stderr:") + print(stderr) + print("smc_storm return code:") + print(return_code) + assert return_code == 0, \ + f"Command failed with return code {return_code}" + return return_code == 0 + + +def test_run_smc_storm(): + """Testing if it is possible to run smc_storm.""" + result =_run_smc_storm("-v") + assert result, "smc_storm failed to run" + + +if __name__ == '__main__': + pytest.main(['-s', '-vv', __file__]) diff --git a/scxml_converter/pyproject.toml b/scxml_converter/pyproject.toml index 4bb37c9d..e1ae4c9e 100644 --- a/scxml_converter/pyproject.toml +++ b/scxml_converter/pyproject.toml @@ -17,7 +17,8 @@ classifiers = [ ] keywords = [] dependencies = [ - + "networkx", + "btlib", ] requires-python = ">=3.7" @@ -25,4 +26,7 @@ requires-python = ">=3.7" dev = ["pytest", "pytest-cov", "pycodestyle", "flake8", "mypy", "isort", "bumpver"] [isort] -profile = "google" \ No newline at end of file +profile = "google" + +[flake8] +max_line_length = 100 \ No newline at end of file diff --git a/scxml_converter/src/scxml_converter/bt_converter.py b/scxml_converter/src/scxml_converter/bt_converter.py new file mode 100644 index 00000000..dc12c6af --- /dev/null +++ b/scxml_converter/src/scxml_converter/bt_converter.py @@ -0,0 +1,164 @@ +# 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. + +""" +Convert Behavior Trees (BT xml) to SCXML. + + +""" + +import os +import xml.etree.ElementTree as ET +from enum import Enum, auto +from typing import List + +import networkx as nx +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) + + +class BT_EVENT_TYPE(Enum): + """Event types for Behavior Tree.""" + TICK = auto() + SUCCESS = auto() + FAILURE = auto() + RUNNING = auto() + + def from_str(event_name: str) -> 'BT_EVENT_TYPE': + event_name = event_name.replace('event=', '') + event_name = event_name.replace('"', '') + event_name = event_name.replace('bt_', '') + return BT_EVENT_TYPE[event_name.upper()] + + +def bt_event_name(node_id: str, event_type: BT_EVENT_TYPE) -> str: + """Return the event name for the given node and event type.""" + return f'bt_{node_id}_{event_type.name.lower()}' + + +def bt_converter( + bt_xml_path: str, + bt_plugins_scxml_paths: List[str], + output_folder: str +): + """ + 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. + """ + bt_graph, xpi = xml_to_networkx(bt_xml_path) + generated_files = [] + + bt_plugins_scxml = {} + 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 + + leaf_node_ids = [] + 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: + leaf_node_ids.append(node) + assert 'NAME' in bt_graph.nodes[node], 'Leaf node must have a type.' + node_type = bt_graph.nodes[node]['NAME'] + node_id = node + assert node_type in bt_plugins_scxml, \ + 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) + 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") + for node in fsm_graph.nodes: + state = ScxmlState(node) + if '_' in 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))) + for edge in fsm_graph.edges(node): + target = edge[1] + transition = ScxmlTransition(target) + if node_id and node_id in leaf_node_ids: + if 'label' not in fsm_graph.edges[edge]: + continue + label = fsm_graph.edges[edge]['label'] + if label == 'on_success': + event_type = BT_EVENT_TYPE.SUCCESS + elif label == 'on_failure': + event_type = BT_EVENT_TYPE.FAILURE + elif label == 'on_running': + event_type = BT_EVENT_TYPE.RUNNING + else: + raise ValueError(f'Invalid label: {label}') + event_name = bt_event_name(node_id, event_type) + 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) + + rtr = RosTimeRate("bt_tick", 1.0) + root_tag.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(ET.tostring(root_tag.as_xml(), encoding='unicode', xml_declaration=True)) + + return generated_files diff --git a/scxml_converter/src/scxml_converter/scxml_converter.py b/scxml_converter/src/scxml_converter/scxml_converter.py index 4e499818..5e527f3b 100644 --- a/scxml_converter/src/scxml_converter/scxml_converter.py +++ b/scxml_converter/src/scxml_converter/scxml_converter.py @@ -14,18 +14,19 @@ # limitations under the License. """ -Facilitation conversion between ScXML flavors. +Facilitation conversion between SCXML flavors. This module provides functionalities to convert the ROS-specific macros -into generic ScXML code. +into generic SCXML code. """ import xml.etree.ElementTree as ET -from copy import deepcopy from typing import Dict, List, Tuple, Union -from mc_toolchain_jani_common.common import (ValidTypes, remove_namespace, - ros_type_name_to_python_type) +from scxml_converter.scxml_entries import ScxmlRoot + +from mc_toolchain_jani_common.common import (remove_namespace, + ros_type_name_to_python_type) from mc_toolchain_jani_common.ecmascript_interpretation import \ interpret_ecma_script_expr @@ -102,6 +103,7 @@ def _check_topic_type( f"expected {expected_python_type}") +# TODO: Not used anymore def convert_elem(elem: ET.Element, parent_map: Dict[ET.Element, ET.Element], type_per_topic: Dict[str, dict], @@ -110,9 +112,10 @@ def convert_elem(elem: ET.Element, timers: Dict[str, float], timer_count: Dict[str, int] ) -> bool: - """Convert an element from the ScXML file. + """ + Convert an element from the SCXML file. - This takes ROS-specific elements and converts them into generic ScXML + This takes ROS-specific elements and converts them into generic SCXML elements. :param elem: The element to convert. @@ -140,11 +143,12 @@ def convert_elem(elem: ET.Element, return True # Publish ################################################################# - if tag_wo_ns == 'ros_publish': - assert elem.attrib['topic'] in type_per_topic - assert elem.attrib['topic'] in published_topics + if tag_wo_ns == 'ros_topic_publish': + topic = elem.attrib['topic'] + assert topic in type_per_topic + assert topic in published_topics elem.tag = 'send' - event_name = f"ros_topic.{elem.attrib['topic']}" + event_name = f"ros_topic.{topic}" elem.attrib.pop('topic') elem.attrib['event'] = event_name return False @@ -169,7 +173,8 @@ def convert_elem(elem: ET.Element, return False # Callback ################################################################ - if tag_wo_ns == 'ros_callback': + if tag_wo_ns == 'ros_topic_callback': + topic = elem.attrib['topic'] assert elem.attrib['topic'] in type_per_topic assert elem.attrib['topic'] in subscribed_topics elem.tag = 'transition' @@ -208,37 +213,12 @@ def convert_elem(elem: ET.Element, def ros_to_scxml_converter(input_xml: str) -> Tuple[str, List[Tuple[str, float]]]: - """Convert one ScXML file that contains ROS-specific tags. + """Convert one SCXML file that contains ROS-specific tags. - :param input_file: The input ScXML file. - :return: The converted ScXML and the timers as a list of tuples. + :param input_file: The input SCXML file. + :return: The converted SCXML and the timers as a list of tuples. Each tuple contains the timer name and the rate in Hz. """ - ET.register_namespace('', 'http://www.w3.org/2005/07/scxml') - try: - tree = ET.fromstring(input_xml) - except ET.ParseError as e: - print(">>>>") - print(input_xml) - print(">>>>") - raise ValueError(f"Error parsing XML: {e}") - type_per_topic = {} - subscribed_topics = [] - published_topics = [] - timers = {} - timer_count = {} - parent_map = {child: parent - for parent in tree.iter() for child in parent} - for elem in list(tree.iter()): - delete = convert_elem( - elem, - parent_map, - type_per_topic, - subscribed_topics, - published_topics, - timers, - timer_count, - ) - if delete: - tree.remove(elem) - return ET.tostring(tree, encoding='unicode'), timers.items() + scxml_root = ScxmlRoot.from_scxml_file(input_xml) + plain_scxml, timers = scxml_root.as_plain_scxml() + return ET.tostring(plain_scxml.as_xml(), encoding='unicode'), timers diff --git a/scxml_converter/src/scxml_converter/scxml_entries/__init__.py b/scxml_converter/src/scxml_converter/scxml_entries/__init__.py new file mode 100644 index 00000000..15c46f23 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/__init__.py @@ -0,0 +1,15 @@ +from .scxml_base import ScxmlBase # noqa: F401 +from .utils import as_plain_scxml_msg_expression, HelperRosDeclarations # noqa: F401 +from .scxml_data_model import ScxmlDataModel # noqa: F401 +from .scxml_param import ScxmlParam # 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 (execution_body_from_xml, # noqa: F401 + as_plain_execution_body, # noqa: F401 + execution_entry_from_xml, valid_execution_body) # noqa: F401 +from .scxml_transition import ScxmlTransition # noqa: F401 +from .scxml_ros_entries import (RosTimeRate, RosTopicPublisher, RosTopicSubscriber, # noqa: F401 + RosRateCallback, RosTopicCallback, RosTopicPublish, # noqa: F401 + RosField, ScxmlRosDeclarations) # noqa: F401 +from .scxml_state import ScxmlState # noqa: F401 +from .scxml_root import ScxmlRoot # noqa: F401 diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_base.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_base.py new file mode 100644 index 00000000..4641ddd4 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_base.py @@ -0,0 +1,42 @@ +# 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. + +""" +Base SCXML class, defining the methods all SCXML entries shall implement. +""" + + +class ScxmlBase: + """This class is the base class for all SCXML entries.""" + + def get_tag_name() -> str: + """Get the tag name of the XML element.""" + raise NotImplementedError + + def from_xml_tree(xml_tree) -> "ScxmlBase": + """Create a ScxmlBase object from an XML tree.""" + raise NotImplementedError + + def check_validity(self) -> bool: + """Check if the object is valid.""" + raise NotImplementedError + + def as_plain_scxml(self, ros_declarations) -> "ScxmlBase": + """Convert the object to its plain SCXML version.""" + raise NotImplementedError + + def as_xml(self): + """Convert the object to an XML element.""" + raise NotImplementedError 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 new file mode 100644 index 00000000..562f900c --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py @@ -0,0 +1,82 @@ +# 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. + +""" +Container for the variables defined in the SCXML model. In XML, it has the tag `datamodel`. +""" + +from scxml_converter.scxml_entries import ScxmlBase + +from typing import List, Optional, Tuple + +from xml.etree import ElementTree as ET + +ScxmlData = Tuple[str, Optional[str]] + + +class ScxmlDataModel(ScxmlBase): + """This class represents the variables defined in the model.""" + def __init__(self, data_entries: List[ScxmlData] = None): + self._data_entries = data_entries + + def get_tag_name() -> str: + return "datamodel" + + 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." + data_entries = [] + for data_entry_xml in data_entries_xml: + name = data_entry_xml.attrib.get("id") + assert name is not None, "Error: SCXML datamodel: 'id' not found for data entry." + expr = data_entry_xml.attrib.get("expr", None) + data_entries.append((name, expr)) + return ScxmlDataModel(data_entries) + + 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, tuple) and len(data_entry) == 2 + if not valid_data_entry: + valid_data_entries = False + break + name, expr = data_entry + valid_name = isinstance(name, str) and len(name) > 0 + valid_expr = expr is None or isinstance(expr, str) + if not valid_name or not valid_expr: + valid_data_entries = False + break + if not valid_data_entries: + print("Error: SCXML datamodel: data entries are not valid.") + return valid_data_entries + + def as_xml(self) -> Optional[ET.Element]: + assert self.check_validity(), "SCXML: found invalid datamodel object." + if self._data_entries is None or len(self._data_entries) == 0: + return None + xml_datamodel = ET.Element(ScxmlDataModel.get_tag_name()) + for data_entry in self._data_entries: + name, expr = data_entry + xml_data = ET.Element("data", {"id": name}) + if expr is not None: + xml_data.set("expr", expr) + xml_datamodel.append(xml_data) + return xml_datamodel 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 new file mode 100644 index 00000000..f29cfd50 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py @@ -0,0 +1,352 @@ +# 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. + +""" +Definition of SCXML Tags that can be part of executable content +""" + +from typing import List, Optional, Union, Tuple, get_args +from xml.etree import ElementTree as ET + +from scxml_converter.scxml_entries import (ScxmlBase, ScxmlParam, HelperRosDeclarations, + as_plain_scxml_msg_expression) + +# Use delayed type evaluation: https://peps.python.org/pep-0484/#forward-references +ScxmlExecutableEntry = Union['ScxmlAssign', 'ScxmlIf', 'ScxmlSend'] +ScxmlExecutionBody = List[ScxmlExecutableEntry] +ConditionalExecutionBody = Tuple[str, ScxmlExecutionBody] + + +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 + + def get_tag_name() -> str: + return "if" + + def get_conditional_executions(self) -> List[ConditionalExecutionBody]: + """Get the conditional executions.""" + return self._conditional_executions + + def get_else_execution(self) -> Optional[ScxmlExecutionBody]: + """Get the else execution.""" + return self._else_execution + + 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()}." + conditions: List[str] = [] + exec_bodies: List[ScxmlExecutionBody] = [] + conditions.append(xml_tree.attrib["cond"]) + current_body: ScxmlExecutionBody = [] + for child in xml_tree: + if child.tag == "elseif": + conditions.append(child.attrib["cond"]) + exec_bodies.append(current_body) + current_body = [] + elif child.tag == "else": + exec_bodies.append(current_body) + current_body = [] + else: + current_body.append(execution_entry_from_xml(child)) + 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) + + def check_validity(self) -> bool: + valid_conditional_executions = len(self._conditional_executions) > 0 + 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 + valid_else_execution = \ + self._else_execution is None or 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 + + def check_valid_ros_instantiations(self, ros_declarations: HelperRosDeclarations) -> bool: + """Check if the ros instantiations have been declared.""" + # Check the executable content + assert isinstance(ros_declarations, HelperRosDeclarations), \ + "Error: SCXML if: invalid ROS declarations type provided." + for _, exec_body in self._conditional_executions: + 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 + return True + + def as_plain_scxml(self, ros_declarations: HelperRosDeclarations) -> "ScxmlIf": + condional_executions = [] + for condition, execution in self._conditional_executions: + condional_executions.append((as_plain_scxml_msg_expression(condition), + as_plain_execution_body(execution, ros_declarations))) + else_execution = as_plain_execution_body(self._else_execution, ros_declarations) + return ScxmlIf(condional_executions, else_execution) + + def as_xml(self) -> ET.Element: + # Based on example in https://www.w3.org/TR/scxml/#if + assert self.check_validity(), "SCXML: found invalid if object." + first_conditional_execution = self._conditional_executions[0] + xml_if = ET.Element(ScxmlIf.get_tag_name(), {"cond": first_conditional_execution[0]}) + append_execution_body_to_xml(xml_if, first_conditional_execution[1]) + 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: + xml_if.append(ET.Element('else')) + append_execution_body_to_xml(xml_if, self._else_execution) + return xml_if + + +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 + + def get_tag_name() -> str: + return "send" + + def get_event(self) -> str: + """Get the event to send.""" + return self._event + + def get_params(self) -> List[ScxmlParam]: + """Get the parameters to send.""" + return self._params + + def from_xml_tree(xml_tree: ET.Element) -> "ScxmlSend": + """Create a ScxmlSend object from an XML tree.""" + assert xml_tree.tag == ScxmlSend.get_tag_name(), \ + f"Error: SCXML send: XML tag name is not {ScxmlSend.get_tag_name()}." + event = xml_tree.attrib["event"] + params = [] + for param_xml in xml_tree: + params.append(ScxmlParam.from_xml_tree(param_xml)) + if len(params) == 0: + params = None + return ScxmlSend(event, params) + + def check_validity(self) -> bool: + valid_event = isinstance(self._event, str) and len(self._event) > 0 + valid_params = True + for param in self._params: + valid_param = isinstance(param, ScxmlParam) and param.check_validity() + valid_params = valid_params and valid_param + if not valid_event: + print("Error: SCXML send: event is not valid.") + if not valid_params: + print("Error: SCXML send: one or more param entries are not valid.") + return valid_event and valid_params + + def check_valid_ros_instantiations(self, _) -> bool: + """Check if the ros instantiations have been declared.""" + # This has nothing to do with ROS. Return always True + return True + + def append_param(self, param: ScxmlParam) -> None: + assert isinstance(param, ScxmlParam), "Error: SCXML send: invalid param." + self._params.append(param) + + def as_plain_scxml(self, _) -> "ScxmlSend": + return self + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "SCXML: found invalid send object." + xml_send = ET.Element(ScxmlSend.get_tag_name(), {"event": self._event}) + for param in self._params: + xml_send.append(param.as_xml()) + return xml_send + + +class ScxmlAssign(ScxmlBase): + """This class represents a variable assignment.""" + + def __init__(self, location: str, expr: str): + self._location = location + self._expr = expr + + def get_tag_name() -> str: + return "assign" + + def get_location(self) -> str: + """Get the location to assign.""" + return self._location + + def get_expr(self) -> str: + """Get the expression to assign.""" + return self._expr + + 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." + return ScxmlAssign(location, expr) + + 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.") + return valid_location and valid_expr + + def check_valid_ros_instantiations(self, _) -> bool: + """Check if the ros instantiations have been declared.""" + # This has nothing to do with ROS. Return always True + return True + + def as_plain_scxml(self, _) -> "ScxmlAssign": + # TODO: Might make sense to check if the assignment happens in a topic callback + expr = as_plain_scxml_msg_expression(self._expr) + return ScxmlAssign(self._location, expr) + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "SCXML: found invalid assign object." + return ET.Element(ScxmlAssign.get_tag_name(), { + "location": self._location, "expr": self._expr}) + + +# Get the resolved types from the forward references in ScxmlExecutableEntry +_ResolvedScxmlExecutableEntry = \ + tuple(entry._evaluate(globals(), locals(), frozenset()) + for entry in get_args(ScxmlExecutableEntry)) + + +def valid_execution_body(execution_body: ScxmlExecutionBody) -> bool: + """ + Check if an execution body is valid. + + :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 + + +def execution_entry_from_xml(xml_tree: ET.Element) -> ScxmlExecutableEntry: + """ + Create an execution entry from an XML tree. + + :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 RosTopicPublish + # Switch based on the tag name + exec_tag = xml_tree.tag + if exec_tag == ScxmlIf.get_tag_name(): + return ScxmlIf.from_xml_tree(xml_tree) + elif exec_tag == ScxmlAssign.get_tag_name(): + return ScxmlAssign.from_xml_tree(xml_tree) + elif exec_tag == ScxmlSend.get_tag_name(): + return ScxmlSend.from_xml_tree(xml_tree) + elif exec_tag == RosTopicPublish.get_tag_name(): + return RosTopicPublish.from_xml_tree(xml_tree) + else: + raise ValueError(f"Error: SCXML conversion: tag {exec_tag} isn't an executable entry.") + + +def execution_body_from_xml(xml_tree: ET.Element) -> ScxmlExecutionBody: + """ + Create an execution body from an XML tree. + + :param xml_tree: The XML tree to create the execution body from + :return: The execution body + """ + exec_body: ScxmlExecutionBody = [] + for exec_elem_xml in xml_tree: + exec_body.append(execution_entry_from_xml(exec_elem_xml)) + return exec_body + + +def append_execution_body_to_xml(xml_parent: ET.Element, exec_body: ScxmlExecutionBody) -> None: + """ + Append an execution body to an existing XML element. + + :param xml_parent: The parent XML element to append the executable entries to + :param exec_body: The execution body to append + """ + for exec_entry in exec_body: + xml_parent.append(exec_entry.as_xml()) + + +def as_plain_execution_body( + exec_body: Optional[ScxmlExecutionBody], + ros_declarations: HelperRosDeclarations) -> Optional[ScxmlExecutionBody]: + """ + Convert the execution body to plain SCXML. + + :param exec_body: The execution body to convert + :param ros_declarations: The ROS declarations + :return: The converted execution body + """ + if exec_body is None: + return None + return [entry.as_plain_scxml(ros_declarations) for entry in exec_body] diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_param.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_param.py new file mode 100644 index 00000000..0ad9f245 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_param.py @@ -0,0 +1,89 @@ +# 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. + +""" +Container for a single parameter, sent within an event. In XML, it has the tag `param`. +""" + +from scxml_converter.scxml_entries import ScxmlBase + +from typing import Optional + +from xml.etree import ElementTree as ET + + +class ScxmlParam(ScxmlBase): + """This class represents a single parameter.""" + + def __init__(self, name: str, *, expr: Optional[str] = None, location: Optional[str] = None): + self._name = name + self._expr = expr + self._location = location + + def get_tag_name() -> str: + return "param" + + def get_name(self) -> str: + return self._name + + def get_expr(self) -> Optional[str]: + return self._expr + + def get_location(self) -> Optional[str]: + return self._location + + 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." + return ScxmlParam(name, expr=expr, location=location) + + 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) + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "SCXML: found invalid param." + xml_param = ET.Element(ScxmlParam.get_tag_name(), {"name": self._name}) + if self._expr is not None: + xml_param.set("expr", self._expr) + if self._location is not None: + xml_param.set("location", self._location) + return xml_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 new file mode 100644 index 00000000..23ce822c --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py @@ -0,0 +1,229 @@ +# 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. + +""" +The main entry point of an SCXML Model. In XML, it has the tag `scxml`. +""" + +from typing import List, Optional, Tuple, get_args +from scxml_converter.scxml_entries import (ScxmlBase, ScxmlState, ScxmlDataModel, + ScxmlRosDeclarations, RosTimeRate, RosTopicSubscriber, + RosTopicPublisher, HelperRosDeclarations) + +from copy import deepcopy +from os.path import isfile + +from xml.etree import ElementTree as ET + + +class ScxmlRoot(ScxmlBase): + """This class represents a whole scxml model, that is used to define specific skills.""" + + def __init__(self, name: str): + self._name = name + self._version = "1.0" # This is the only version mentioned in the official documentation + self._initial_state: str = None + self._states: List[ScxmlState] = [] + self._data_model: ScxmlDataModel = None + self._ros_declarations: List[ScxmlRosDeclarations] = None + + def get_tag_name() -> str: + return "scxml" + + def get_states(self) -> List[ScxmlState]: + return self._states + + def get_state_by_id(self, state_id: str) -> Optional[ScxmlState]: + for state in self._states: + if state.get_id() == state_id: + return state + return None + + 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." + # Data Model + datamodel_elements = xml_tree.findall(ScxmlDataModel.get_tag_name()) + 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 = [] + 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)) + # 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." + # 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 + # States + for state_element in state_elements: + scxml_state = ScxmlState.from_xml_tree(state_element) + is_initial = scxml_state.get_id() == initial_state + scxml_root.add_state(scxml_state, initial=is_initial) + return scxml_root + + 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() + elif xml_file.startswith(" Optional[HelperRosDeclarations]: + """Generate a HelperRosDeclarations object from the existing ROS declarations.""" + ros_decl_container = HelperRosDeclarations() + if self._ros_declarations is not None: + for ros_declaration in self._ros_declarations: + if not ros_declaration.check_validity(): + 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_declaration.get_topic_type()) + elif isinstance(ros_declaration, RosTopicPublisher): + ros_decl_container.append_publisher(ros_declaration.get_topic_name(), + ros_declaration.get_topic_type()) + else: + raise ValueError("Error: SCXML root: invalid ROS declaration type.") + 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_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.") + if not valid_data_model: + print("Error: SCXML root: datamodel is not valid.") + valid_ros = self._check_valid_ros_declarations() + if not valid_ros: + print("Error: SCXML root: ROS declarations are not valid.") + return valid_name and valid_initial_state and valid_states and valid_data_model and \ + valid_ros + + def _check_valid_ros_declarations(self) -> bool: + """Check if the ros declarations and instantiations are valid.""" + # Prepare the ROS declarations, to check no undefined ros instances exist + ros_decl_container = self._generate_ros_declarations_helper() + 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 + 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 + + def as_plain_scxml(self) -> Tuple["ScxmlRoot", List[Tuple[str, float]]]: + """ + 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 + """ + if self.is_plain_scxml(): + return self + # 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 + ros_declarations = self._generate_ros_declarations_helper() + 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, list(ros_declarations.get_timers().items())) + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "SCXML: found invalid root object." + xml_root = ET.Element("scxml", { + "name": self._name, + "version": self._version, + "model_src": "", + "initial": self._initial_state, + "xmlns": "http://www.w3.org/2005/07/scxml" + }) + if self._data_model is not None: + xml_root.append(self._data_model.as_xml()) + if self._ros_declarations is not None: + for ros_declaration in self._ros_declarations: + xml_root.append(ros_declaration.as_xml()) + for state in self._states: + xml_root.append(state.as_xml()) + ET.indent(xml_root, " ") + return xml_root 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 new file mode 100644 index 00000000..4631fedf --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_entries.py @@ -0,0 +1,476 @@ +# 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 List, Optional, Union +from scxml_converter.scxml_entries import (ScxmlBase, ScxmlSend, ScxmlParam, ScxmlTransition, + ScxmlExecutionBody, HelperRosDeclarations, + valid_execution_body, execution_body_from_xml, + as_plain_execution_body, as_plain_scxml_msg_expression) +from xml.etree import ElementTree as ET + + +def _check_topic_type_known(topic_definition: str) -> bool: + """Check if python can import the provided topic definition.""" + # Check the input type has the expected structure + if not (isinstance(topic_definition, str) and topic_definition.count("/") == 1): + return False + topic_ns, topic_type = topic_definition.split("/") + if len(topic_ns) == 0 or len(topic_type) == 0: + return False + try: + msg_importer = __import__(topic_ns + '.msg', fromlist=['']) + _ = getattr(msg_importer, topic_type) + except (ImportError, AttributeError): + print(f"Error: SCXML ROS declarations: topic type {topic_definition} not found.") + return False + return True + + +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) + + def get_tag_name() -> str: + return "ros_time_rate" + + 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 = xml_tree.attrib.get("rate_hz") + assert timer_name is not None and timer_rate is not None, \ + "Error: SCXML rate timer: 'name' or 'rate_hz' attribute not found in input xml." + try: + timer_rate = float(timer_rate) + except ValueError: + raise ValueError("Error: SCXML rate timer: rate is not a number.") + return RosTimeRate(timer_name, timer_rate) + + 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 + 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 get_name(self) -> str: + return self._name + + def get_rate(self) -> float: + return self._rate_hz + + def as_plain_scxml(self, _) -> ScxmlBase: + 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( + RosTimeRate.get_tag_name(), {"rate_hz": str(self._rate_hz), "name": self._name}) + return xml_time_rate + + +class RosTopicPublisher(ScxmlBase): + """Object used in SCXML root to declare a new topic publisher.""" + + def __init__(self, topic_name: str, topic_type: str) -> None: + self._topic_name = topic_name + self._topic_type = topic_type + + def get_tag_name() -> str: + return "ros_topic_publisher" + + 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 check_validity(self) -> bool: + valid_name = isinstance(self._topic_name, str) and len(self._topic_name) > 0 + valid_type = _check_topic_type_known(self._topic_type) + if not valid_name: + print("Error: SCXML topic subscriber: topic name is not valid.") + if not valid_type: + print("Error: SCXML topic subscriber: topic type is not valid.") + return valid_name and valid_type + + def get_topic_name(self) -> str: + return self._topic_name + + def get_topic_type(self) -> str: + return self._topic_type + + def as_plain_scxml(self, _) -> ScxmlBase: + 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(), {"topic": self._topic_name, "type": self._topic_type}) + return xml_topic_publisher + + +class RosTopicSubscriber(ScxmlBase): + """Object used in SCXML root to declare a new topic subscriber.""" + + def __init__(self, topic_name: str, topic_type: str) -> None: + self._topic_name = topic_name + self._topic_type = topic_type + + def get_tag_name() -> str: + return "ros_topic_subscriber" + + 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 check_validity(self) -> bool: + valid_name = isinstance(self._topic_name, str) and len(self._topic_name) > 0 + valid_type = _check_topic_type_known(self._topic_type) + if not valid_name: + print("Error: SCXML topic subscriber: topic name is not valid.") + if not valid_type: + print("Error: SCXML topic subscriber: topic type is not valid.") + return valid_name and valid_type + + def get_topic_name(self) -> str: + return self._topic_name + + def get_topic_type(self) -> str: + return self._topic_type + + def as_plain_scxml(self, _) -> ScxmlBase: + 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(), + {"topic": self._topic_name, "type": self._topic_type}) + return xml_topic_subscriber + + +class RosRateCallback(ScxmlTransition): + """Callback that triggers each time the associated timer ticks.""" + + 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_tag_name() -> str: + return "ros_rate_callback" + + 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 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: HelperRosDeclarations) -> bool: + """Check if the ros instantiations have been declared.""" + assert isinstance(ros_declarations, HelperRosDeclarations), \ + "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 = self._check_valid_ros_instantiations_exec_body(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: HelperRosDeclarations) -> 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 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 + + +class RosTopicCallback(ScxmlTransition): + """Object representing a transition to perform when a new ROS msg is received.""" + + def __init__( + self, topic: Union[RosTopicSubscriber, str], target: str, + condition: Optional[str] = None, body: Optional[ScxmlExecutionBody] = None): + """ + Create a new ros_topic_callback object instance. + + :param topic: 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() + else: + # Used for generating ROS entries from xml file + assert isinstance(topic, str), "Error: SCXML topic callback: invalid topic type." + self._topic = topic + self._target = target + self._condition = condition + self._body = body + assert self.check_validity(), "Error: SCXML topic callback: invalid parameters." + + def get_tag_name() -> str: + return "ros_topic_callback" + + 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." + 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 RosTopicCallback(topic_name, target, condition, exec_body) + + 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_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_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_cond: + print("Error: SCXML topic callback: condition is not valid.") + if not valid_body: + print("Error: SCXML topic callback: body is not valid.") + return valid_topic and valid_target and valid_cond and valid_body + + def check_valid_ros_instantiations(self, ros_declarations: HelperRosDeclarations) -> bool: + """Check if the ros instantiations have been declared.""" + assert isinstance(ros_declarations, HelperRosDeclarations), \ + "Error: SCXML topic callback: invalid ROS declarations container." + topic_cb_declared = ros_declarations.is_subscriber_defined(self._topic) + if not topic_cb_declared: + print(f"Error: SCXML topic callback: topic subscriber {self._topic} not declared.") + return False + valid_body = self._check_valid_ros_instantiations_exec_body(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: HelperRosDeclarations) -> ScxmlTransition: + event_name = "ros_topic." + self._topic + target = self._target + cond = self._condition + body = as_plain_execution_body(self._body, ros_declarations) + return ScxmlTransition(target, [event_name], cond, 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", {"topic": self._topic, "target": self._target}) + if self._condition is not None: + xml_topic_callback.set("cond", self._condition) + if self._body is not None: + for entry in self._body: + xml_topic_callback.append(entry.as_xml()) + return xml_topic_callback + + +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." + + def get_tag_name() -> str: + return "field" + + 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." + return RosField(name, expr) + + 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.") + return valid_name and valid_expr + + def as_plain_scxml(self) -> ScxmlParam: + return ScxmlParam(self._name, expr=as_plain_scxml_msg_expression(self._expr)) + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "Error: SCXML topic publish field: invalid parameters." + xml_field = ET.Element(RosField.get_tag_name(), {"name": self._name, "expr": self._expr}) + return xml_field + + +class RosTopicPublish(ScxmlSend): + """Object representing the shipping of a ROS msg through a topic.""" + + def __init__(self, topic: Union[RosTopicPublisher, str], + fields: Optional[List[RosField]] = None): + if isinstance(topic, RosTopicPublisher): + self._topic = topic.get_topic_name() + else: + # Used for generating ROS entries from xml file + assert isinstance(topic, str), "Error: SCXML topic publish: invalid topic type." + self._topic = topic + self._fields = fields + assert self.check_validity(), "Error: SCXML topic publish: invalid parameters." + + def get_tag_name() -> str: + return "ros_topic_publish" + + 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 = [] + for field_xml in xml_tree: + fields.append(RosField.from_xml_tree(field_xml)) + if len(fields) == 0: + fields = None + return RosTopicPublish(topic_name, fields) + + def check_validity(self) -> bool: + valid_topic = isinstance(self._topic, str) and len(self._topic) > 0 + valid_fields = self._fields is None or \ + all([isinstance(field, RosField) 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 + + def check_valid_ros_instantiations(self, ros_declarations: HelperRosDeclarations) -> bool: + """Check if the ros instantiations have been declared.""" + assert isinstance(ros_declarations, HelperRosDeclarations), \ + "Error: SCXML topic publish: invalid ROS declarations container." + topic_pub_declared = ros_declarations.is_publisher_defined(self._topic) + if not topic_pub_declared: + print(f"Error: SCXML topic publish: topic {self._topic} 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 as_plain_scxml(self, _) -> ScxmlSend: + event_name = "ros_topic." + self._topic + params = None if self._fields is None else \ + [field.as_plain_scxml() 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}) + if self._fields is not None: + for field in self._fields: + xml_topic_publish.append(field.as_xml()) + return xml_topic_publish + + +ScxmlRosDeclarations = Union[RosTimeRate, RosTopicPublisher, RosTopicSubscriber] diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py new file mode 100644 index 00000000..3eda8d44 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py @@ -0,0 +1,184 @@ +# 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. + +""" +A single state in SCXML. In XML, it has the tag `state`. +""" + +from typing import List, Optional, Union +from xml.etree import ElementTree as ET + +from scxml_converter.scxml_entries import (ScxmlBase, ScxmlExecutableEntry, ScxmlExecutionBody, + ScxmlTransition, HelperRosDeclarations, + as_plain_execution_body, execution_body_from_xml, + valid_execution_body) + + +class ScxmlState(ScxmlBase): + """This class represents a single scxml state.""" + + def __init__(self, id: str, *, + on_entry: Optional[ScxmlExecutionBody] = None, + on_exit: Optional[ScxmlExecutionBody] = None, + body: Optional[List[ScxmlTransition]] = None): + self._id = id + self._on_entry = on_entry + self._on_exit = on_exit + self._body = body + + def get_tag_name() -> str: + return "state" + + def get_onentry(self) -> Optional[ScxmlExecutionBody]: + return self._on_entry + + def get_onexit(self) -> Optional[ScxmlExecutionBody]: + return self._on_exit + + def from_xml_tree(xml_tree: ET.Element) -> "ScxmlState": + """Create a ScxmlState object from an XML tree.""" + assert xml_tree.tag == ScxmlState.get_tag_name(), \ + f"Error: SCXML state: XML tag name is not {ScxmlState.get_tag_name()}." + id = xml_tree.attrib.get("id") + 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_tree.findall("onentry") + if on_entry is not None and len(on_entry) == 0: + on_entry = None + assert on_entry is None or len(on_entry) == 1, \ + f"Error: SCXML state: {len(on_entry)} onentry tags found, expected 0 or 1." + on_exit = xml_tree.findall("onexit") + if on_exit is not None and len(on_exit) == 0: + on_exit = None + assert on_exit is None or len(on_exit) == 1, \ + f"Error: SCXML state: {len(on_exit)} onexit tags found, expected 0 or 1." + if on_entry is not None: + for exec_entry in execution_body_from_xml(on_entry[0]): + scxml_state.append_on_entry(exec_entry) + if on_exit is not None: + 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): + scxml_state.add_transition(body_entry) + return scxml_state + + def _transitions_from_xml(xml_tree: ET.Element) -> List[ScxmlTransition]: + # import ros callbacks inheriting from ScxmlTransition + from .scxml_ros_entries import RosRateCallback, RosTopicCallback + transitions: List[ScxmlTransition] = [] + for child in xml_tree: + if child.tag == ScxmlTransition.get_tag_name(): + transitions.append(ScxmlTransition.from_xml_tree(child)) + elif child.tag == RosRateCallback.get_tag_name(): + transitions.append(RosRateCallback.from_xml_tree(child)) + elif child.tag == RosTopicCallback.get_tag_name(): + transitions.append(RosTopicCallback.from_xml_tree(child)) + return transitions + + def get_id(self) -> str: + return self._id + + def add_transition(self, transition: ScxmlTransition): + if self._body is None: + self._body = [] + self._body.append(transition) + + def append_on_entry(self, executable_entry: ScxmlExecutableEntry): + if self._on_entry is None: + self._on_entry = [] + self._on_entry.append(executable_entry) + + def append_on_exit(self, executable_entry: ScxmlExecutableEntry): + if self._on_exit is None: + self._on_exit = [] + self._on_exit.append(executable_entry) + + def check_validity(self) -> bool: + valid_id = isinstance(self._id, str) and len(self._id) > 0 + valid_on_entry = self._on_entry is None or valid_execution_body(self._on_entry) + valid_on_exit = self._on_exit is None or valid_execution_body(self._on_exit) + valid_body = True + if self._body is not None: + valid_body = isinstance(self._body, list) + if valid_body: + for transition in self._body: + valid_transition = isinstance( + transition, ScxmlTransition) and transition.check_validity() + if not valid_transition: + valid_body = False + break + 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.") + if not valid_on_exit: + print("Error: SCXML state: on_exit is not valid.") + if not valid_body: + print("Error: SCXML state: executable body is not valid.") + return valid_on_entry and valid_on_exit and valid_body + + def check_valid_ros_instantiations(self, ros_declarations: HelperRosDeclarations) -> bool: + """Check if the ros instantiations have been declared.""" + # Check onentry and onexit + valid_entry = ScxmlState._check_valid_ros_instantiations(self._on_entry, + ros_declarations) + 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.") + if not valid_exit: + print("Error: SCXML state: onexit has invalid ROS instantiations.") + if not valid_body: + print("Error: SCXML state: found invalid transition in state body.") + return valid_entry and valid_exit and valid_body + + def _check_valid_ros_instantiations(body: List[Union[ScxmlExecutableEntry, ScxmlTransition]], + ros_declarations: HelperRosDeclarations) -> bool: + """Check if the ros instantiations have been declared in the body.""" + if body is None: + return True + for entry in body: + if not entry.check_valid_ros_instantiations(ros_declarations): + return False + return True + + def as_plain_scxml(self, ros_declarations: HelperRosDeclarations) -> "ScxmlState": + """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 = as_plain_execution_body(self._body, ros_declarations) + return ScxmlState(self._id, on_entry=plain_entry, on_exit=plain_exit, body=plain_body) + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "SCXML: found invalid state object." + xml_state = ET.Element(ScxmlState.get_tag_name(), {"id": self._id}) + if self._on_entry is not None: + xml_on_entry = ET.Element('onentry') + for executable_entry in self._on_entry: + xml_on_entry.append(executable_entry.as_xml()) + xml_state.append(xml_on_entry) + if self._on_exit is not None: + xml_on_exit = ET.Element('onexit') + for executable_entry in self._on_exit: + xml_on_exit.append(executable_entry.as_xml()) + xml_state.append(xml_on_exit) + if self._body is not None: + for transition in self._body: + xml_state.append(transition.as_xml()) + return xml_state diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py new file mode 100644 index 00000000..8dc31820 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py @@ -0,0 +1,141 @@ +# 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. + +""" +A single transition in SCXML. In XML, it has the tag `transition`. +""" + +from typing import List, Optional +from scxml_converter.scxml_entries import (ScxmlBase, ScxmlExecutionBody, ScxmlExecutableEntry, + HelperRosDeclarations, valid_execution_body, + execution_body_from_xml) + +from xml.etree import ElementTree as ET + + +class ScxmlTransition(ScxmlBase): + """This class represents a single scxml state.""" + + def __init__(self, + target: str, events: Optional[List[str]] = None, condition: Optional[str] = None, + body: Optional[ScxmlExecutionBody] = None): + """ + Generate a new transition. Currently, transitions must have a target. + + :param target: The state transition goes to. Required (unlike in SCXML specifications) + :param events: The events that trigger this transition. + :param condition: The condition guard to enable/disable the transition + :param body: Content that is executed when the transition happens + """ + assert isinstance(target, str) and len( + target) > 0, "Error SCXML transition: target must be a non-empty string." + assert events is None or (isinstance(events, list) and + all((isinstance(ev, str) and len(ev) > 0) for ev in events)), \ + "Error SCXML transition: events must be a list of non-empty strings." + 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." + self._target = target + self._body = body + self._events = events + self._condition = condition + + def get_tag_name() -> str: + return "transition" + + def from_xml_tree(xml_tree: ET.Element) -> "ScxmlTransition": + """Create a ScxmlTransition object from an XML tree.""" + assert xml_tree.tag == ScxmlTransition.get_tag_name(), \ + f"Error: SCXML transition: XML root tag name is not {ScxmlTransition.get_tag_name()}." + target = xml_tree.get("target") + assert target is not None, "Error: SCXML transition: target attribute not found." + events = xml_tree.get("event") + events = events.split(" ") if events is not None else None + condition = xml_tree.get("cond") + exec_body = execution_body_from_xml(xml_tree) + exec_body = exec_body if exec_body is not None else None + return ScxmlTransition(target, events, condition, exec_body) + + def add_event(self, event: str): + if self._events is None: + self._events = [] + self._events.append(event) + + 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." + + def check_validity(self) -> bool: + valid_target = isinstance(self._target, str) and len(self._target) > 0 + valid_events = self._events is None or \ + (isinstance(self._events, list) and all(isinstance(ev, str) for ev in self._events)) + valid_condition = 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_target: + print("Error: SCXML transition: target is not valid.") + if not valid_events: + print("Error: SCXML transition: events are not valid.\nList of events:") + for event in self._events: + print(f"\t-'{event}'.") + if not valid_condition: + print("Error: SCXML transition: condition is not valid.") + if not valid_body: + print("Error: SCXML transition: executable content is not valid.") + return valid_target and valid_events and valid_condition and valid_body + + def check_valid_ros_instantiations(self, ros_declarations: HelperRosDeclarations) -> bool: + """Check if the ros instantiations have been declared.""" + # Check the executable content + valid_body = self._check_valid_ros_instantiations_exec_body(ros_declarations) + if not valid_body: + print("Error: SCXML transition: executable content has invalid ROS instantiations.") + return valid_body + + def _check_valid_ros_instantiations_exec_body(self, + ros_declarations: HelperRosDeclarations) -> bool: + """Check if the ros instantiations have been declared in the executable body.""" + assert isinstance(ros_declarations, HelperRosDeclarations), \ + "Error: SCXML transition: invalid ROS declarations container." + if self._body is None: + return True + for entry in self._body: + if not entry.check_valid_ros_instantiations(ros_declarations): + return False + return True + + def as_plain_scxml(self, ros_declarations: HelperRosDeclarations) -> "ScxmlTransition": + assert isinstance(ros_declarations, HelperRosDeclarations), \ + "Error: SCXML transition: invalid ROS declarations container." + new_body = None + if self._body is not None: + new_body = [entry.as_plain_scxml(ros_declarations) for entry in self._body] + return ScxmlTransition(self._target, self._events, self._condition, new_body) + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "SCXML: found invalid transition." + xml_transition = ET.Element(ScxmlTransition.get_tag_name(), {"target": self._target}) + if self._events is not None: + xml_transition.set("event", " ".join(self._events)) + if self._condition is not None: + xml_transition.set("cond", self._condition) + if self._body is not None: + for executable_entry in self._body: + xml_transition.append(executable_entry.as_xml()) + return xml_transition diff --git a/scxml_converter/src/scxml_converter/scxml_entries/utils.py b/scxml_converter/src/scxml_converter/scxml_entries/utils.py new file mode 100644 index 00000000..19b63da9 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/utils.py @@ -0,0 +1,68 @@ +# 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 various utilities for scxml entries.""" + +from typing import Dict + + +def as_plain_scxml_msg_expression(msg_expr: str) -> str: + """Convert a ROS message expression (referring to ROS msg entries) to plain SCXML.""" + prefix = "_event." if msg_expr.startswith("_msg.") else "" + return prefix + msg_expr.removeprefix("_msg.") + + +class HelperRosDeclarations: + """Object that contains a description of the ROS declarations in the SCXML root.""" + + def __init__(self): + # Dict of publishers and subscribers: topic name -> type + self._publishers: Dict[str, str] = {} + self._subscribers: Dict[str, str] = {} + self._timers: Dict[str, float] = {} + + 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_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 diff --git a/scxml_converter/test/_test_data/expected_output_bt_and_plugins/10000_TopicCondition.scxml b/scxml_converter/test/_test_data/expected_output_bt_and_plugins/10000_TopicCondition.scxml new file mode 100644 index 00000000..ec249671 --- /dev/null +++ b/scxml_converter/test/_test_data/expected_output_bt_and_plugins/10000_TopicCondition.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/expected_output_bt_and_plugins/1001_TopicAction.scxml b/scxml_converter/test/_test_data/expected_output_bt_and_plugins/1001_TopicAction.scxml new file mode 100644 index 00000000..b42be1e0 --- /dev/null +++ b/scxml_converter/test/_test_data/expected_output_bt_and_plugins/1001_TopicAction.scxml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/expected_output_bt_and_plugins/bt.scxml b/scxml_converter/test/_test_data/expected_output_bt_and_plugins/bt.scxml new file mode 100644 index 00000000..0dd4e515 --- /dev/null +++ b/scxml_converter/test/_test_data/expected_output_bt_and_plugins/bt.scxml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/expected_output/battery_drainer.scxml b/scxml_converter/test/_test_data/expected_output_ros_to_scxml/battery_drainer.scxml similarity index 100% rename from scxml_converter/test/_test_data/expected_output/battery_drainer.scxml rename to scxml_converter/test/_test_data/expected_output_ros_to_scxml/battery_drainer.scxml diff --git a/scxml_converter/test/_test_data/expected_output/battery_manager.scxml b/scxml_converter/test/_test_data/expected_output_ros_to_scxml/battery_manager.scxml similarity index 100% rename from scxml_converter/test/_test_data/expected_output/battery_manager.scxml rename to scxml_converter/test/_test_data/expected_output_ros_to_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/expected_output_ros_to_scxml/bt_topic_action.scxml new file mode 100644 index 00000000..afc8a495 --- /dev/null +++ b/scxml_converter/test/_test_data/expected_output_ros_to_scxml/bt_topic_action.scxml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/expected_output_ros_to_scxml/bt_topic_condition.scxml b/scxml_converter/test/_test_data/expected_output_ros_to_scxml/bt_topic_condition.scxml new file mode 100644 index 00000000..10fe8354 --- /dev/null +++ b/scxml_converter/test/_test_data/expected_output_ros_to_scxml/bt_topic_condition.scxml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/input_files/battery_drainer.scxml b/scxml_converter/test/_test_data/input_files/battery_drainer.scxml new file mode 100644 index 00000000..b0da1361 --- /dev/null +++ b/scxml_converter/test/_test_data/input_files/battery_drainer.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/battery_drainer_charge/battery_manager.scxml b/scxml_converter/test/_test_data/input_files/battery_manager.scxml similarity index 71% rename from scxml_converter/test/_test_data/battery_drainer_charge/battery_manager.scxml rename to scxml_converter/test/_test_data/input_files/battery_manager.scxml index ffd211f0..1b092d32 100644 --- a/scxml_converter/test/_test_data/battery_drainer_charge/battery_manager.scxml +++ b/scxml_converter/test/_test_data/input_files/battery_manager.scxml @@ -10,10 +10,10 @@ - - + + - - + + diff --git a/scxml_converter/test/_test_data/battery_drainer_charge/bt.xml b/scxml_converter/test/_test_data/input_files/bt.xml similarity index 79% rename from scxml_converter/test/_test_data/battery_drainer_charge/bt.xml rename to scxml_converter/test/_test_data/input_files/bt.xml index 1a07f590..b3a740e1 100644 --- a/scxml_converter/test/_test_data/battery_drainer_charge/bt.xml +++ b/scxml_converter/test/_test_data/input_files/bt.xml @@ -1,10 +1,10 @@ - + - + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/input_files/bt_topic_action.scxml b/scxml_converter/test/_test_data/input_files/bt_topic_action.scxml new file mode 100644 index 00000000..ca15edbc --- /dev/null +++ b/scxml_converter/test/_test_data/input_files/bt_topic_action.scxml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/battery_drainer_charge/bt_topic_condition.scxml b/scxml_converter/test/_test_data/input_files/bt_topic_condition.scxml similarity index 80% rename from scxml_converter/test/_test_data/battery_drainer_charge/bt_topic_condition.scxml rename to scxml_converter/test/_test_data/input_files/bt_topic_condition.scxml index 180db905..71b8f251 100644 --- a/scxml_converter/test/_test_data/battery_drainer_charge/bt_topic_condition.scxml +++ b/scxml_converter/test/_test_data/input_files/bt_topic_condition.scxml @@ -16,16 +16,15 @@ - - - + + + - - + - + diff --git a/scxml_converter/test/_test_data/input_files/invalid_xmls/battery_drainer.scxml b/scxml_converter/test/_test_data/input_files/invalid_xmls/battery_drainer.scxml new file mode 100644 index 00000000..ca82c5da --- /dev/null +++ b/scxml_converter/test/_test_data/input_files/invalid_xmls/battery_drainer.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/input_files/invalid_xmls/bt_topic_action.scxml b/scxml_converter/test/_test_data/input_files/invalid_xmls/bt_topic_action.scxml new file mode 100644 index 00000000..f6c80bc2 --- /dev/null +++ b/scxml_converter/test/_test_data/input_files/invalid_xmls/bt_topic_action.scxml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/test_systemtest_scxml_entries.py b/scxml_converter/test/test_systemtest_scxml_entries.py new file mode 100644 index 00000000..6e6b029a --- /dev/null +++ b/scxml_converter/test/test_systemtest_scxml_entries.py @@ -0,0 +1,172 @@ +# 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. + +import os +from xml.etree import ElementTree as ET + +from scxml_converter.scxml_entries import (ScxmlAssign, ScxmlDataModel, ScxmlParam, ScxmlRoot, + ScxmlSend, ScxmlState, ScxmlTransition, + RosTimeRate, RosTopicPublisher, RosTopicSubscriber, + RosRateCallback, RosTopicPublish, RosTopicCallback, + RosField) +from test_utils import canonicalize_xml, remove_empty_lines + + +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([("battery_percent", "100")])) + use_battery_state = ScxmlState( + "use_battery", + on_entry=[ScxmlSend("ros_topic.level", + [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"], + 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 = ET.tostring(battery_drainer_scxml.as_xml(), encoding='unicode') + 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() + + +def test_battery_drainer_ros_from_code(): + """ + Test for scxml_entries generation and conversion to xml (including ROS specific SCXML extension) + + It should support the following xml tree: + - scxml + - state + - onentry + - {executable content} + - onexit + - {executable content} + - transition / ros_rate_callback / ros_topic_callback + - {executable content} + - datamodel + - data + + Executable content consists of the following entries: + - send + - param + - ros_topic_publish + - field + - if / elseif / else + - assign +""" + battery_drainer_scxml = ScxmlRoot("BatteryDrainer") + battery_drainer_scxml.set_data_model(ScxmlDataModel([("battery_percent", "100")])) + ros_topic_sub = RosTopicSubscriber("charge", "std_msgs/Empty") + ros_topic_pub = RosTopicPublisher("level", "std_msgs/Int32") + ros_timer = RosTimeRate("my_timer", 1) + battery_drainer_scxml.add_ros_declaration(ros_topic_sub) + battery_drainer_scxml.add_ros_declaration(ros_topic_pub) + battery_drainer_scxml.add_ros_declaration(ros_timer) + + use_battery_state = ScxmlState("use_battery") + use_battery_state.append_on_entry( + RosTopicPublish(ros_topic_pub, [RosField("data", "battery_percent")])) + use_battery_state.add_transition( + RosRateCallback(ros_timer, "use_battery", None, + [ScxmlAssign("battery_percent", "battery_percent - 1")])) + use_battery_state.add_transition( + RosTopicCallback(ros_topic_sub, "use_battery", None, + [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 = ET.tostring(battery_drainer_scxml.as_xml(), encoding='unicode') + 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() + + +def _test_xml_parsing(xml_file_path: str, valid_xml: bool = True): + # TODO: Input path to scxml file fro args + scxml_root = ScxmlRoot.from_scxml_file(xml_file_path) + # Check output xml + if valid_xml: + test_output = ET.tostring(scxml_root.as_xml(), encoding='unicode') + 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_xml_parsing_battery_drainer(): + _test_xml_parsing(os.path.join(os.path.dirname(__file__), '_test_data', + 'input_files', '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')) + + +def test_xml_parsing_invalid_battery_drainer_xml(): + _test_xml_parsing(os.path.join(os.path.dirname(__file__), '_test_data', 'input_files', + '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', + 'invalid_xmls', 'bt_topic_action.scxml'), valid_xml=False) + + +if __name__ == '__main__': + test_battery_drainer_from_code() + test_battery_drainer_ros_from_code() + test_xml_parsing_battery_drainer() + test_xml_parsing_bt_topic_condition() + test_xml_parsing_invalid_battery_drainer_xml() + test_xml_parsing_invalid_bt_topic_action_xml() diff --git a/scxml_converter/test/test_systemtest_xml.py b/scxml_converter/test/test_systemtest_xml.py index 28c749bd..f4748ae2 100644 --- a/scxml_converter/test/test_systemtest_xml.py +++ b/scxml_converter/test/test_systemtest_xml.py @@ -14,39 +14,77 @@ # limitations under the License. import os -import xml.etree.ElementTree as ET +from test_utils import canonicalize_xml, remove_empty_lines +from scxml_converter.bt_converter import bt_converter from scxml_converter.scxml_converter import ros_to_scxml_converter -def _canonicalize_xml(xml: str) -> str: - """Helper function to make XML comparable.""" - # sort attributes - et = ET.fromstring(xml) - for elem in et.iter(): - elem.attrib = {k: elem.attrib[k] for k in sorted(elem.attrib.keys())} - return ET.tostring(et, encoding='unicode') +def get_output_folder(): + return os.path.join(os.path.dirname(__file__), 'output') + + +def clear_output_folder(): + output_folder = get_output_folder() + if os.path.exists(output_folder): + for f in os.listdir(output_folder): + os.remove(os.path.join(output_folder, f)) + else: + os.makedirs(output_folder) def test_ros_scxml_to_plain_scxml(): """Test the conversion of SCXML with ROS-specific macros to plain SCXML.""" - for fname in ['battery_manager.scxml', 'battery_drainer.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')] + for fname in scxml_files: input_file = os.path.join(os.path.dirname(__file__), - '_test_data', 'battery_drainer_charge', fname) + '_test_data', 'input_files', fname) output_file = os.path.join(os.path.dirname(__file__), - '_test_data', 'expected_output', fname) - with open(input_file, 'r', encoding='utf-8') as f_i: - input_data = f_i.read() - sms = ros_to_scxml_converter(input_data) - out = sms[0] - with open(output_file, 'r', encoding='utf-8') as f_o: + '_test_data', 'expected_output_ros_to_scxml', fname) + try: + with open(input_file, 'r', encoding='utf-8') as f_i: + input_data = f_i.read() + scxml, _ = ros_to_scxml_converter(input_data) + with open(output_file, 'r', encoding='utf-8') as f_o: + expected_output = f_o.read() + assert remove_empty_lines(canonicalize_xml(scxml)) == \ + remove_empty_lines(canonicalize_xml(expected_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 _canonicalize_xml(out) == _canonicalize_xml(expected_output) + assert remove_empty_lines(canonicalize_xml(output)) == \ + remove_empty_lines(canonicalize_xml(expected_output)) + clear_output_folder() - # if fname == 'battery_drainer.scxml': - # assert len(sms) == 2, "Must also have the time state machine." - # elif fname == 'battery_manager.scxml': - # assert len(sms) == 1, "Must only have the battery state machine." if __name__ == '__main__': test_ros_scxml_to_plain_scxml() diff --git a/scxml_converter/test/test_utils.py b/scxml_converter/test/test_utils.py new file mode 100644 index 00000000..063871e1 --- /dev/null +++ b/scxml_converter/test/test_utils.py @@ -0,0 +1,32 @@ +# 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 xml.etree import ElementTree as ET + + +def canonicalize_xml(xml: str) -> str: + """Helper function to make XML comparable.""" + # sort attributes + assert isinstance(xml, str), f"Error: invalid input: expected str, found {type(xml)}" + et = ET.fromstring(xml) + for elem in et.iter(): + elem.attrib = {k: elem.attrib[k] for k in sorted(elem.attrib.keys())} + return ET.tostring(et, encoding='unicode') + + +def remove_empty_lines(text: str) -> str: + """Remove empty lines from a string.""" + assert isinstance(text, str), f"Error: invalid input: expected str, found {type(text)}" + return "\n".join([line for line in text.split("\n") if line.strip()])