Adding new service¶
MORSE has a concept of services: services are remote procedure calls commonly used to configure or control the simulation’s behaviour.
Each component can register services that are made publicly available to the outside world through middleware-specific channels.
Services can be either synchronous or asynchronous. Synchronous services block the simulation until they complete. They must be fast to execute to avoid slowing down the simulation. Asynchronous services may span computations on several simulation steps (but each individual cycle must be fast).
Exposing methods as services¶
Most of the time, adding a new service is as easy as adding @service
in front of a function declared within a component.
Note
Arguments coming from remote callers are passed to services as strings, so be sure to convert your data to the expected type.
Warning
Do not use eval()
to convert your data from strings to the expected type, as
this would cause a serious security threat. Use type specific functions
(like int()
, float()
,…) instead (or pass the data as JSON
strings).
Lets have a look at human.py
, the component that allows us to control
a human character in the simulation.
import morse.core.robot
from morse.core.services import service
class HumanClass(morse.core.robot.Robot):
def __init__(self, obj, parent=None):
...
@service
def move(self, speed, rotation):
human = self.bge_object
human.applyMovement( [float(speed), 0, 0], True )
human.applyRotation( [0, 0 , float(rotation)], True )
...
By adding the @service
decorator to the move
method, we expose
move
as a MORSE service.
During the simulation initialization, MORSE registers any services for each instance of the component, maps them to one (or several) middlewares (as specified by the scene description), and starts listening for incoming requests.
Each middleware has its own naming scheme for services, but you can
expect the services to be available as component_name.service_name
.
The example below shows a simple Python client that would use the
HumanClass.move
service as declared above:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("localhost", 4000))
s.send("id1 human move [1.0, 1.6]\n")
In this example, we assume that human
is the name of the Blender
object that instantiates a HumanClass
.
Note
The value of the id (here id1
) has no importance at all: it is specific
to the implementation of the services with sockets and used only by the
client to track requests and responses.
Using pymorse, the previous code can be rewritten as:
from pymorse import Morse
with Morse() as morse:
morse.rpc('human', 'move', 1.0, 1.6)
Returning values¶
A service can return any valid Python object (None
, a string, a
dictionary, a complex object, etc.). The serialization is left to the
middleware.
If the service call fails, you are expected to raise a
morse.core.exceptions.MorseRPCInvokationError
exception
(or any custom exception inheriting from it) with a useful error message:
import morse.core.robot
from morse.core.exceptions import MorseRPCInvokationError
from morse.core.services import service
class HumanClass(morse.core.robot.Robot):
def __init__(self, obj, parent=None):
...
@service
def move(self, speed, rotation):
if float(speed) < 0:
raise MorseRPCInvokationError("Our human cannot walk at negative speed!")
human = self.bge_object
human.applyMovement( [float(speed), 0, 0], True )
human.applyRotation( [0, 0, float(rotation)], True )
...
MORSE will answer the request with a
morse.core.status.FAILED
status.
Free functions¶
Synchronous services can also be declared outside classes (on free-functions).
In this case, the decorator takes one parameter, the (pseudo) component.
For instance, morse.services.supervision_services
declares such
services. The following example shows the list_robots
service that
returns the list of robots declared in the simulation:
from morse.core import blenderapi
from morse.core.services import service
@service(component = "simulation")
def list_robots():
return [obj.name for obj in blenderapi.persistantstorage().robotDict.keys()]
The pseudo-component simulation
is used as a namespace for the
service: this one is accessible as simulation.list_robots
.
Asynchronous services¶
RPC calls may be used to start the execution of a task that may take a long time to complete.
In such cases, asynchronous services can be used to initialize and start the task. MORSE automatically notifies the client when the task is completed.
Declaring new asynchronous services is slightly more complex: we first need an initialization method and secondly, a way to tell when the task is completed.
Declaring an initialization method is very similar to synchronous
services. For instance, the waypoint
actuator defines an asynchronous goto
service:
import morse.core.actuator
from morse.core.services import async_service
class Waypoint(morse.core.actuator.Actuator):
def __init__(self, obj, parent=None):
...
@async_service
def goto(self, x, y, z, tolerance=0.5, speed=1.0):
self.local_data['x'] = float(x)
self.local_data['y'] = float(y)
self.local_data['z'] = float(z)
...
The @service
decorator is simply replaced by @async_service
. By
doing so, MORSE automatically registers a callback that is used to
monitor the status of the task and to notify the client upon completion.
In this example we simply set a new target position in the actuator using the
local_data
dictionary, but any kind of initialization can be done
here. However, the work must be done quickly (since the simulator blocks until the
initialization method returns).
The execution of the task itself takes place at each simulation step in
the component’s
morse.core.object.Object.default_action()
method.
Each execution step should remain fast since the simulator also blocks on
calls to the default_action
method.
When the task is completed, the component must notify it by calling
self.completed(status, result)
.
status
is one of the status value defined in morse.core.status
(e.g., SUCCESS
or FAILED
), result
is any valid Python
object.
Note
As you may have noticed, at a given time, only one asynchronous request can be handled by a component. If a second asynchronous request is received, the behaviour may vary, as explained below.
Note
Asynchronous services can normally only exist inside components (i.e.,
they must be declared within a class inheriting from
morse.core.abstractobject.AbstractObject
).
The section Manually registering services explains how to overcome
this constraint.
Interruption policy for asynchronous services¶
As of morse-1.4
, only one asynchronous service may run at a given time.
You can define the behaviour of the simulator when a second request is received either at the middleware level (global policy) or at the individual service level (local policy).
To set a local policy, simply decorate your services with the
@interruptible
and @noninterruptible
decorators
(morse.core.services.interruptible()
and
morse.core.services.noninterruptible()
). These decorators must appear
before the @async_service
decorator.
An interruptible service is preempted when a new asynchronous service is
started by calling the interrupt
method. The interrupt
method is
defined in morse.core.abstractobject.AbstractObject
to send back
to the caller the status morse.core.status.PREEMPTED
. It is
recommended that you
override this behaviour in the component class to ensure the service is
actually interrupted (and remember to call the base class’ interrupt
method, as shown in the example below).
import morse.core.actuator
class Waypoint(morse.core.actuator.Actuator):
def interrupt(self):
self.local_data['x'] = self.position_3d.x
self.local_data['y'] = self.position_3d.y
self.local_data['z'] = self.position_3d.z
self.local_data['speed_local'] = 0
morse.core.actuator.Actuator.interrupt(self)
Note
It is recommended that you always reimplement the interrupt
method even if the
default policy is non-interruptible, as a caller may choose to manually
interrupt the service.
A non-interruptible service triggers a failure (status
morse.core.status.FAILED
) when someone attempts to start a new
asynchronous service.
To set a global policy, you need to catch a
morse.core.exceptions.MorseServiceAlreadyRunningError
exception
when invoking the morse.core.request_manager.RequestManager.on_incoming_request()
method.
This exception has a special member service
that points to the asynchronous
service currently running:
try:
is_synchronous, value = self.on_incoming_request(component, service, params)
except MorseServiceAlreadyRunningError as e:
logger.warning(e.service.__name__ + " is already running!")
Note
A service with a local policy defined (i.e. decorated with either
@interruptible
or @noninterruptible
) will never trigger a
MorseServiceAlreadyRunningError
exception, and thus, the local
policy always overrides the global policy.