What is a controller

A controller in sardana is a piece of software capable of translating between the sardana API and a specific hardware API. Sardana expects a controller to obey a specific API in order to be able to properly configure and operate with it. The hardware API used by the controller could connect directly to the hardware via specific libraries or connect to any standalone server written for example in Tango, Taco or even EPICS. Since Sardana is written in python talking directly to the hardware is not possible if the hardware libraries do not have a python interface. For the rest of the cases both solutions are usually valid and is up to the developers to evaluate and decide which of botho solutions is the most convenient one. Having a standalone server could be more convenient for debugging or testing purporses or for having access to the hardware without the need of running sardana, but it requires more work in the implementation, since both standalone server and controller have to be developed. This does not apply in case the hardware is already integrated or controlled by another system.

Controllers can only be written in Python (in future also C++ will be possible). A controller must be a class inheriting from one of the existing controller types:

A controller is designed to incorporate a set of generic individual elements. Each element has a corresponding axis. For example, in a motor controller the elements will be motors, but in a counter/timer controller the elements will be experimental channels.

Some controller classes are designed to target a specific type of hardware. Other classes of controllers, the pseudo classes, are designed to provide a high level view over a set of underlying lower level controller elements.

We will focus first on writing low level hardware controllers since they share some of the API and after on the pseudo controllers.

Controller - The basics

The first thing to do is to import the necessary symbols from sardana library. As you will see, most symbols can be imported through the sardana.pool.controller module:

import springfieldlib

from sardana.pool.controller import MotorController

class SpringfieldMotorController(MotorController):
    """A motor controller intended for demonstration purposes only"""
    pass

The common API to all low level controllers includes the set of methods to:

  1. construct the controller

  2. add/delete a controller element [1]

  3. obtain the state of controller element(s) [2]

  4. define, set and get extra axis attributes

  5. define, set and get extra controller attributes

  6. define, set and get extra controller properties

In the following chapters the examples will be based on a motor controller scenario.

The examples use a springfieldlib module which emulates a motor hardware access library.

The springfieldlib can be downloaded from here.

The Springfield motor controller can be downloaded from here.

Constructor

The constructor consists of the __init__() method. This method is called when you create a new controller of that type and every time the sardana server is started. It will also be called if the controller code has changed on the file and the new code is reloaded into sardana.

It is NOT mandatory to override the __init__() from MotorController . Do it only if you need to add some initialization code. If you do it, it is very important to follow the two rules:

  1. use the method signature: __init__(self, inst, props, *args, **kwargs)

  2. always call the super class constructor

The example shows how to implement a constructor for a motor controller:

class SpringfieldMotorController(MotorController):

    def __init__(self, inst, props, *args, **kwargs):
        super(SpringfieldMotorController, self).__init__(inst, props, *args, **kwargs)

        # initialize hardware communication
        self.springfield = springfieldlib.SpringfieldMotorHW()

        # do some initialization
        self._motors = {}

Add/Delete axis

Each individual element in a controller is called axis. An axis is represented by a number. A controller can support one or more axes. Axis numbers don’t need to be sequencial. For example, at one time you may have created for your motor controller instance only axis 2 and 5.

Two methods are called when creating or removing an element from a controller. These methods are AddDevice() and DeleteDevice(). The AddDevice() method is called when a new axis belonging to the controller is created in sardana. The DeleteDevice() method is called when an axis belonging to the controller is removed from sardana. These methods are also called when the sardana server is started and if the controller code has changed on the file and the new code is reloaded into sardana.

The example shows an example how to implement these methods on a motor controller:

class SpringfieldMotorController(MotorController):

    def AddDevice(self, axis):
        self._motors[axis] = True

    def DeleteDevice(self, axis):
        del self._motor[axis]

Get axis state

To get the state of an axis, sardana calls the StateOne() method. This method receives an axis as parameter and should return either:

  • state (State) or

  • a sequence of two elements:

(For motor controller see get motor state ):

The state should be a member of State (For backward compatibility reasons, it is also supported to return one of PyTango.DevState). The status could be any string.

If you return a State object, sardana will compose a status string with:

<axis name> is in <state name>

Here is an example of the possible implementation of StateOne() :

from sardana import State

class SpringfieldMotorController(MotorController):

    StateMap = {
        1 : State.On,
        2 : State.Moving,
        3 : State.Fault,
    }

    def StateOne(self, axis):
        springfield = self.springfield
        state = self.StateMap[ springfield.getState(axis) ]
        status = springfield.getStatus(axis)
        return state, status

Extra axis attributes

Each axis is associated a set of standard attributes. These attributes depend on the type of controller (example, a motor will have velocity, acceleration but a counter won’t).

Additionally, you can specify an additional set of extra attributes on each axis.

Lets suppose that a Springfield motor controller can do close loop on hardware. We could define an extra motor attribute on each axis that (de)actives close loop on demand.

The first thing to do is to specify which are the extra attributes. This is done through the axis_attributes. This is basically a dictionary where the keys are attribute names and the value is a dictionary describing the folowing properties for each attribute:

config. parameter

Mandatory

Key

Default value

Example

data type & format

Yes

Type

int

data access

No

Access

ReadWrite

ReadOnly

description

No

Description

“” (empty string)

“the motor encoder source”

default value

No

DefaultValue

12345

getter method name

No

FGet

“get” + <name>

“getEncoderSource”

setter method name

No

FSet

“set” + <name>

“setEncoderSource”

memorize value

No

Memorize

Memorized

NotMemorized

max dimension size

No

MaxDimSize

Scalar: (); 1D: (2048,); 2D: (2048, 2048)

(2048,)

Here is an example of how to specify the scalar, boolean, read-write CloseLoop extra attribute in a Springfield motor controller:

from sardana import DataAccess
from sardana.pool.controller import Type, Description, DefaultValue, Access, FGet, FSet

class SpringfieldMotorController(MotorController):

    axis_attributes = {
        "CloseLoop" : {
                Type         : bool,
                Description  : "(de)activates the motor close loop algorithm",
                DefaultValue : False,
            },
    }

    def getCloseLoop(self, axis):
        return self.springfield.isCloseLoopActive(axis)

    def setCloseLoop(self, axis, value):
        self.springfield.setCloseLoop(axis, value)

When sardana needs to read the close loop value, it will first check if the controller has the method specified by the FGet keyword (we didn’t specify it in axis_attributes so it defaults to getCloseLoop). It will then call this controller method which should return a value compatible with the attribute data type.

As an alternative, to avoid filling the controller code with pairs of get/set methods, you can choose not to write the getCloseLoop and setCloseLoop methods. This will trigger sardana to call the GetAxisExtraPar() /SetAxisExtraPar() pair of methods. The disadvantage is you will end up with a forest of ifelifelse statements. Here is the alternative implementation:

from sardana import DataAccess
from sardana.pool.controller import Type, Description, DefaultValue, Access, FGet, FSet

class SpringfieldMotorController(MotorController):

    axis_attributes = {
        "CloseLoop" : {
                Type         : bool,
                Description  : "(de)activates the motor close loop algorithm",
                DefaultValue : False,
            },
    }

    def GetAxisExtraPar(self, axis, parameter):
        if parameter == 'CloseLoop':
            return self.springfield.isCloseLoopActive(axis)

    def SetAxisExtraPar(self, axis, parameter, value):
        if parameter == 'CloseLoop':
             self.springfield.setCloseLoop(axis, value)

Sardana gives you the choice: we leave it up to you to decide which is the better option for your specific case.

Extra controller attributes

Besides extra attributes per axis, you can also define extra attributes at the controller level. In order to do that you have to specify the extra controller attribute(s) within the ctrl_attributes member. The syntax for this dictionary is the same as the one used for axis_attributes.

Here is an example on how to specify a read-only float matrix attribute called ReflectionMatrix at the controller level:

class SpringfieldMotorController(MotorController):

    ctrl_attributes = {
        "ReflectionMatrix" : {
                Type         : ( (float,), ),
                Description  : "The reflection matrix",
                Access : DataAccess.ReadOnly,
            },
    }

    def getReflectionMatrix(self):
        return ( (1.0, 0.0), (0.0, 1.0) )

Or, similar to what you can do with axis attributes:

class SpringfieldMotorController(MotorController):

    ctrl_attributes = \
    {
        "ReflectionMatrix" : {
                Type         : ( (float,), ),
                Description  : "The reflection matrix",
                Access : DataAccess.ReadOnly,
            },
    }

    def GetCtrlPar(self, name):
        if name == "ReflectionMatrix":
            return ( (1.0, 0.0), (0.0, 1.0) )

Restoring memorized attributes

Controller or axis attributes have Memorize config property which if set to Memorized makes sardana restore the memorized values to the controller (plugin) instance during:

  • server startup

  • reconfig macro execution

Order of restoring these values corresponds to, for controller attributes, ctrl_attributes definition, and for axis attributes:

  1. standard_axis_attributes definition

  2. axis_attributes definition

Each controller type e.g. MotorController or CounterTimerController has a different set of standard axis attributes. You can consult their order in the sardana source code.

Note

In Python 3.7 or higher (or eventually CPython 3.6) dict order is guaranteed to be insertion order. In older Python versions you must use OrderedDict when declaring ctrl_attributes or axis_attributes.

If an attribute’s write value must be memorized in the Tango Database but not applied to the attribute at startup, set the Memorize config property to MemorizedNoInit. In this configuration, the attribute’s setter method will not be executed during server startup, nor during reconfig macro execution.

Extra controller properties

A more static form of attributes can be defined at the controller level. These properties are loaded into the controller at the time of object construction. They are accessible to your controller at any time but it is not possible for a user from outside to modify them. The way to define ctrl_properties is very similar to the way you define extra axis attributes or extra controller attributes.

Here is an example on how to specify a host and port properties:

class SpringfieldMotorController(MotorController):

    ctrl_properties = \
    {
        "host" : {
                Type : str,
                Description : "host name"
            },
        "port" : {
                Type : int,
                Description : "port number",
                DefaultValue: springfieldlib.SpringfieldMotorHW.DefaultPort
           },
    }

    def __init__(self, inst, props, *args, **kwargs):
        super(SpringfieldMotorController, self).__init__(inst, props, *args, **kwargs)

        host = self.host
        port = self.port

        # initialize hardware communication
        self.springfield = springfieldlib.SpringfieldMotorHW(host=host, port=port)

        # do some initialization
        self._motors = {}

As you can see from lines 15 and 16, to access your controller properties simply use self.<property name>. Sardana assures that every property has a value. In our case, when a SpringfieldMotorController is created, if port property is not specified by the user (example: using the defctrl macro in spock), sardana assignes the default value springfieldlib.SpringfieldMotorHW.DefaultPort. On the other hand, since host has no default value, if it is not specified by the user, sardana will complain and fail to create and instance of SpringfieldMotorController.

Changing default interface

Elements instantiated from your controller will have a default interface corresponding to the controller’s type. For example a moveable will have a position attribute or an experimental channel will have a value attribute. However this default interface can be changed if necessary.

For example, the default type of a moveable’s position attribute float can be changed to int if the given axis only allows discrete positions. To do that simple override the GetAxisAttributes where you can apply the necessary changes.

Here is an example of how to change motor’s position attribute to int:

def GetAxisAttributes(self, axis):
    axis_attrs = MotorController.GetAxisAttributes(self, axis)
    axis_attrs = dict(axis_attrs)
    axis_attrs['Position']['type'] = int
    return axis_attrs

Error handling

When you write a controller it is important to properly handle errors (example: motor power overload, hit a limit switch, lost of communication with the hardware).

These are the two basic sardana rules you should have in mind:

  1. The exceptions which are not handled by the controller are handled by sardana, usually by re-raising the exception (when sardana runs as a Tango DS a translation is done from the Python exception to a Tango exception). The StateOne() method is handled a little differently: the state is set to Fault and the status will contain the exception information.

  2. When the methods which are supposed to return a value (like GetAxisPar()) don’t return a value compatible with the expected data type (including None) a TypeError exception is thrown.

In every method you should carefully choose how to do handle the possible exceptions/errors.

Usually, catch and handle is the best technique since it is the code of your controller which knows exactly the workings of the hardware. You can discriminate errors and decide a proper handle for each. Essencially, this technique consists of:

  1. catching the error (if an exception: with tryexcept clause, if an expected return of a function: with a ifelifelse statement, etc)

  2. raise a proper exception (could be the same exception that has been catched) or, if in StateOne(), return the apropriate error state (Fault, Alarm) and a descriptive status.

Here is an example: if the documentation of the underlying library says that:

reading the motor closeloop raises CommunicationFailed if it is not possible to communicate with the Springfield hardware

reading the motor state raises MotorPowerOverload if the motors has a power overload or a MotorTempTooHigh when the motor temperature is too high

then you should handle the exception in the controller and return a proper state information:

def getCloseLoop(self, axis):
    # Here the "proper exception" to raise in case of error is actually the
    # one that is raised from the springfield library so handling the
    # exception is transparent. Nice!
    return self.springfield.isCloseLoopActive(axis)

def StateOne(self, axis):
    springfield = self.springfield

    try:
        state = self.StateMap[ springfield.getState(axis) ]
        status = springfield.getStatus(axis)
    except springfieldlib.MotorPowerOverload:
        state = State.Fault
        status = "Motor has a power overload"
    except springfieldlib.MotorTempTooHigh:
        temp = springfield.getTemperature(axis)
        state = State.Alarm
        status = "Motor temperature is too high (%f degrees)" % temp

    limit_switches = MotorController.NoLimitSwitch
    hw_limit_switches = springfield.getLimits(axis)
    if hw_limit_switches[0]:
        limit_switches |= MotorController.HomeLimitSwitch
    if hw_limit_switches[1]:
        limit_switches |= MotorController.UpperLimitSwitch
    if hw_limit_switches[2]:
        limit_switches |= MotorController.LowerLimitSwitch
    return state, status, limit_switches

Hiding the exception is usually a BAD technique since it prevents the user from finding what was the cause of the problem. You should only use it in extreme cases (example: if there is a bug in sardana which crashes the server if you try to properly raise an exception, then you can temporarely use this technique until the bug is solved).

Example:

def getCloseLoop(self, axis):
    # BAD error handling technique
    try:
        return self.springfield.isCloseLoopActive(axis)
    except:
        pass

Accessing Tango from your controllers

It is a very common pattern that when integrating a new hardware (or eventually a software component) in Sardana you start from a Tango Device Server (either already existing one or you develop one as an intermediate layer). In this case your controller will need to access Tango and there are two ways of doing that, either with Taurus (using taurus.Device) or with PyTango (using tango.DeviceProxy). Please consult a similar discussion Accessing Tango from your macros on which one to use.

For accessing Sardana elements e.g.: motors, experimental channels, etc. currently there is no Sardana API and you will need to use one of the above methods.

Note

For a very simplified integration of Tango devices in Sardana you may consider using sardana-tango controllers.

Footnotes