Middlelayer API Fundamentals

The design of the Karabo Middle-Layer API

In Karabo, every device has a schema, which contains all the details about the expected parameters, its types, comments, units, everything. It is only broadcast rarely over the network, typically only during the initial handshake with the device. Once the schema is known, only configurations, or even only parts of configurations, are sent over the network in a tree structure called Hash (which is not a hash table).

These configurations know nothing anymore about the meaning of the values they contain, yet they are very strictly typed: even different bit sizes of integers are conserved.

The Karabo basetypes

The Karabo basetypes were designed to ease the use of all the features of Karabo expected parameters, namely the fact that they have units and timestamps. Given that most devices in a scientific control system are typically written in a very rapid prototyping manner, and given that one of Karabo’s goals is to enable many users to quickly write proper Karabo devices, it is obvious that most device programmers won’t care about proper treatment of timestamps, let alone units.

This is why we do that automatically. For the unit part, we use pint, while the timestamps part had to be written by us.

Bulk-set of properties

In a synchronous context, setting a parameter is synchronous, hence the code blocks until the parameter is properly set or an error is raised.

In a coroutine, however, all parameter settings are automatically cached and will not be sent before the next await. It is guaranteed that parameter settings are properly ordered and are sent before the next slot call in bulk.

As a corollary, setting a parameter multiple times results in only one setting on the device of the last value. If this is not desired, use update device as follows:

proxy.someValue = 3
await updateDevice(proxy)
proxy.someValue = 5

Synchronized Functions

There are many functions in Karabo which do not instantaneously execute. Frequently, it is important that other code can continue running while such a function is still executing. For the ease of use, all those functions, which are documented here as synchronized, follow the same calling convention, namely, they have a set of additional keyword parameters to allow for non-blocking calls to them:

timeout

gives a timeout in seconds. If the function is not done after it timed out, a TimeoutError will be raised, unless the timeout is -1, meaning infinite timeout. The executed function will be canceled once it times out.

callback

instead of blocking until the function is done, it returns immediately. Once the function is done, the supplied callback will be executed. The function returns a Future object, described below; the callback will get the same future object passed as its only parameter.

If callback is None, the function still returns immediately a future, but no callback is called.

What is a Karabo Future

The future object contains everything to manage asynchronous operations:

class Future
cancel()

Cancel the running function. The running function will stop executing as soon as possible.

cancelled()

Return whether the function was canceled

done()

Return whether the function is done, so returned normally, raised an exception or was canceled.

result()

Return the result of the function, or raise an error if the function did so.

exception()

Return the exception the function raised, or None.

add_done_callback(cb)

Add a callback to be run once the function is done. It gets passed the future as the single parameter.

wait()

wait for the function to finish

Tasks: background

You can call your own synchronized functions and launch them in the background:

background(func, *args, **kwargs)

Call the function func with args and kwargs.

The function passed is wrapped as a synchronized function. In a very simple description the func gets called in the background.

The background function will create and return a task which can be cancelled. A CancelledError is raised in the called function, which allows you to react to the cancellation, including ignoring it:

@Slot(displayedName="Start",
      description="Starts task")
@coroutine
def start(self):
    self.task = background(self.start_scan)

@Slot(displayedName="Stop",
      description="Stops task")
@coroutine
def stop(self):
    if self.task:
        self.task.cancel()
        self.task = None

@coroutine
def start_scan(self):
    try:
        ... do something here ...
    except CancelledError:
        ... react on cancellation ...

Note

background() creates and runs a thread if and only if the passed function is not a coroutine, otherwise the coroutine is simply scheduled on the event loop.

Sleep nicely!

You should always prefer the middlelayer sleep function over time.sleep. The asyncio sleep can be canceled and is not a blocking call.

sleep(delay)

Stop execution for at least delay seconds.

This is a synchronized function, so it may also be used to schedule the calling of a callback function at a later time.

Note

If a unit is provided, the sleep function will account for it.

Locking

A locked device will only allow read-only access to its properties by a device not holding the lock. Similarly command execution is restricted to the lock holder:

@Slot(displayedName="Perform X-scan")
def perform(self):
    with getDevice("some_device") as device:
        with (await lock(device)):
            # do something useful here
lock(device)

lock the device for exclusive use by this owner device.

The function returns a context manager to be used in a with statement.

The parameter lockedBy of a device contains the current owner of the lock, or an empty string if nobody holds a lock.

Synchronous or Asynchronous

Although property access via device proxies is usually to be preferred, there are scenarios where only a single or very few interactions with a remote device are necessary. In such a case the following shorthands may be used:

await setWait("deviceId", "someOtherParameter", a)
await execute("deviceId", "someSlot")

The aforementioned commands are blocking and synchronized coroutines.

Additionally, non-blocking methods are provided, indicated by the suffix NoWait to each command:

def callback(deviceId, parameterName, value):
    #do something with value
    ...

setNoWait("deviceId", "someOtherParameter", a)
executeNoWait("deviceId", "someSlot", callback=callback)

As shown in the code example a non-blocking property retrieval is realized by supplying a callback when the value is available. The callback for executeNoWait is optional and will be triggered when the execute completes.

The executeNoWait method without callback is internally implemented by sending a fire-and-forget signal to the remote device.

If a callback is given, instead a blocking signal is launched in co-routine, triggering the callback upon completion. The executeNoWait call will immediately return though.