Communication between statecharts

It is not unusual to have to deal with multiple distinct components in which the behavior of a component is driven by things that happen in the other components. One can model such a situation using a single statechart with parallel states, or by plugging several statecharts into one main statechart (see sismic.model.Statechart.copy_from_statechart()). The communication and synchronization between the components can be done either by using active(state_name) in guards, or by sending internal events that address other components.

However, we believe that this approach is not very convenient:

  • all the components must be defined in a single statechart;
  • state name collision could occur;
  • components must share a single execution context;
  • component composition is not easy to achieve
  • ...

Sismic allows to define multiple components in multiple statecharts, and brings a way for those statecharts to communicate and synchronize via events.

Binding statecharts

Every instance of Interpreter exposes a bind() method which allows to bind statecharts.

Interpreter.bind(interpreter_or_callable: typing.Union[typing.Interpreter, typing.Callable[[sismic.model.events.Event], typing.Any]]) → sismic.interpreter.interpreter.Interpreter

Bind an interpreter or a callable to the current interpreter. Each time an internal event is sent by this interpreter, any bound object will be called with the same event. If interpreter_or_callable is an Interpreter instance, its queue method is called. This is, if i1 and i2 are interpreters, i1.bind(i2) is equivalent to i1.bind(i2.queue).

Parameters:interpreter_or_callable – interpreter or callable to bind
Returns:self so it can be chained

When an interpreter interpreter_1 is bound to an interpreter interpreter_2 using interpreter_1.bind(interpreter_2), the internal events that are sent by interpreter_1 are automatically propagated as external events to interpreter_2. The binding is not restricted to only two statecharts. For example, assume we have three instances of Interpreter:

assert isinstance(interpreter_1, Interpreter)
assert isinstance(interpreter_2, Interpreter)
assert isinstance(interpreter_3, Interpreter)

We define a bidirectional communication between the two first interpreters:

interpreter_1.bind(interpreter_2)
interpreter_2.bind(interpreter_1)

We also bind the third interpreters with the two first ones. Notice that bind() returns the current interpreter, so multiple calls can be chained:

interpreter_3.bind(interpreter_1).bind(interpreter_2)

When an internal event is sent by an interpreter, the bound interpreters also receive this event as an external event. In the last example, when an internal event is sent by interpreter_3, then a corresponding external event is sent both to interpreter_1 and interpreter_2.

Note

Practically, unless you subclassed Interpreter, the only difference between internal and external events are the priority order in which they are processed by the interpreter.

from sismic.model import InternalEvent, Event

# Manually create and raise an internal event
interpreter_3.raise_event(InternalEvent('test'))

print('Events for interpreter_1:', interpreter_1._external_events.pop())
print('Events for interpreter_2:', interpreter_2._external_events.pop())
print('Events for interpreter_3:', interpreter_3._internal_events.pop())
Events for interpreter_1: Event('test')
Events for interpreter_2: Event('test')
Events for interpreter_3: InternalEvent('test')

Example

Consider our running example, the elevator statechart. This statechart expects to receive floorSelected events (with a floor parameter representing the selected floor). The statechart operates autonomously, provided that we send such events.

Let us define a new statechart that models a panel of buttons for our elevator. For example, we consider that our panel has 4 buttons numbered 0 to 3.

statechart:
  name: Elevator buttons
  description: |
    Buttons that remotely control the elevator.
  root state:
    name: active
    parallel states:
      - name: button_0
        transitions:
          - event: button_0_pushed
            action: send('floorSelected', floor= 0)
      - name: button_1
        transitions:
          - event: button_1_pushed
            action: send('floorSelected', floor= 1)
      - name: button_2
        transitions:
          - event: button_2_pushed
            action: send('floorSelected', floor= 2)
      - name: button_3
        transitions:
          - event: button_3_pushed
            action: send('floorSelected', floor= 3)

As you can see in the YAML version of this statechart, the panel expects an event for each button: button_0_pushed, button_1_pushed, button_2_pushed and button_3_pushed. Each of those event causes the execution of a transition which, in turn, creates and sends a floorSelected event. The floor parameter of this event corresponds to the button number.

We bind our panel with our elevator, such that the panel can control the elevator:

from sismic.io import import_from_yaml
from sismic.interpreter import Interpreter
from sismic.model import Event, InternalEvent

elevator = Interpreter(import_from_yaml(open('examples/elevator/elevator.yaml')))
buttons = Interpreter(import_from_yaml(open('examples/elevator/elevator_buttons.yaml')))

# Elevator will receive events from buttons
buttons.bind(elevator)

Events that are sent to buttons are not propagated, but events that are sent by buttons are automatically propagated to elevator:

print('Awaiting events in buttons:', list(buttons._external_events))  # Empty
buttons.queue(Event('button_2_pushed'))

print('Awaiting events in buttons:', list(buttons._external_events))  # External event

buttons.execute(max_steps=2)  # (1) initialize buttons, and (2) consume button_2_pushed
print('Awaiting events in buttons:', list(buttons._internal_events))
print('Awaiting events in elevator:', list(elevator._external_events))
Awaiting events in buttons: []
Awaiting events in buttons: [Event('button_2_pushed')]
Awaiting events in buttons: [InternalEvent('floorSelected', floor=2)]
Awaiting events in elevator: [Event('floorSelected', floor=2)]

The execution of bound statecharts does not differ from the execution of unbound statecharts:

elevator.execute()
print('Current floor:', elevator.context.get('current'))
Current floor: 2