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()])