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 if … elif … else 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) )

When there is no override of GetCtrlPar() / SetCtrlPar() methods in the Controller and no FGet/ FSet specific functions are defined for the attribute, Sardana will read and write by default a protected member of the controller called _lowercaseattrname, in the previous example it would be called _reflectionmatrix.

Custom commands

Extra attributes cover values you read and write. Some hardware operations are not values at all but actions: calibrate an axis, reset a counter, reload a firmware table. For these you can declare custom commands, which Sardana exposes as Tango commands on the element and controller devices.

Added in version 3.7.0.

Important

Custom commands require PyTango >= 10.0. On older versions the commands are silently not created — a RuntimeWarning is issued in the server log and everything else keeps working. Sardana itself only requires PyTango >= 9.2.5, so check your installation before relying on this feature.

There are two class members, and the difference is which device gets the command:

ctrl_commands

commands added to the controller device.

axis_commands

commands added to every element (axis) of the controller.

Both are a list of tuple, one tuple per command. Only the first field is mandatory:

position

Mandatory

Meaning

Default value

Example

1 fname

Yes

name of the controller method to call

—

"CalibrateAxis"

2 dtype_in

No

Tango input data type

DevVoid

CmdArgType.DevDouble

3 dformat_in

No

Tango input data format

SCALAR

AttrDataFormat.SCALAR

4 doc_in

No

input documentation

"" (empty string)

"gain value to set"

5 dtype_out

No

Tango output data type

DevVoid

CmdArgType.DevBoolean

6 dformat_out

No

Tango output data format

SCALAR

AttrDataFormat.SCALAR

7 doc_out

No

output documentation

"" (empty string)

"calibration success"

8 display_level

No

Tango display level

OPERATOR

DispLevel.EXPERT

9 cmd_green_mode

No

run through the green mode executor

True

False

The implementing method is looked up on the controller by fname. Its signature differs between the two members, and this is the part that is easiest to get wrong:

  • an axis command receives the axis number as its first argument: def MyCommand(self, axis), or def MyCommand(self, axis, value) if dtype_in is not DevVoid. Sardana binds the axis for you, so Tango clients call the command with the user argument only.

  • a controller command does not: def MyCommand(self), or def MyCommand(self, value).

Here is a Springfield motor controller offering one command per axis and one on the controller:

from PyTango import AttrDataFormat, CmdArgType, DispLevel

from sardana.pool.controller import MotorController

class SpringfieldMotorController(MotorController):

    axis_commands = [
        ("CalibrateAxis",
         CmdArgType.DevVoid, AttrDataFormat.SCALAR, "",
         CmdArgType.DevBoolean, AttrDataFormat.SCALAR, "calibration success",
         DispLevel.OPERATOR),
        ("SetGain",
         CmdArgType.DevDouble, AttrDataFormat.SCALAR, "gain value to set",
         CmdArgType.DevDouble, AttrDataFormat.SCALAR, "gain that was set",
         DispLevel.EXPERT),
    ]

    ctrl_commands = [
        ("ResetAllAxes",
         CmdArgType.DevVoid, AttrDataFormat.SCALAR, "",
         CmdArgType.DevBoolean, AttrDataFormat.SCALAR, "reset success",
         DispLevel.OPERATOR),
    ]

    def CalibrateAxis(self, axis):
        return self.springfield.calibrate(axis)

    def SetGain(self, axis, value):
        self.springfield.setGain(axis, value)
        return self.springfield.getGain(axis)

    def ResetAllAxes(self):
        self.springfield.resetAll()
        return True

Given a controller instance springfieldctrl01 with motors mot01 and mot02, this adds CalibrateAxis and SetGain to both mot01 and mot02, and ResetAllAxes to springfieldctrl01. From Spock:

Door_demo1_1 [1]: mot01.CalibrateAxis()
Result [1]: True

Door_demo1_1 [2]: mot01.SetGain(2.5)
Result [2]: 2.5

Note

cmd_green_mode requires PyTango >= 10.1. On PyTango 10.0 it is forced to False regardless of what you declare, with a RuntimeWarning in the server log.

Warning

Each tuple is unpacked into the command registration call, so a malformed entry — or declaring these members as a dict rather than a list — prevents the commands from being created, and the failure is only visible in the server log. If your commands do not show up on the device, check the log first.

See also

Per-axis custom commands

how to give different axes different commands

controller API reference

the axis_commands and ctrl_commands members

Per-axis custom commands

axis_commands is a plain list, so every entry in it lands on every axis. When the axes of one controller are not interchangeable — a mixed crate, or a calibration routine that only some stages support — declare the commands programmatically instead, by overriding GetAxisCommands(). Sardana calls it once per axis, with that axis number, and uses whatever list it returns:

class SpringfieldMotorController(MotorController):

    # no axis_commands here - the override provides everything

    def GetAxisCommands(self, axis):
        if not self.springfield.supportsCalibration(axis):
            return []
        return [
            ("CalibrateAxis",
             CmdArgType.DevVoid, AttrDataFormat.SCALAR, "",
             CmdArgType.DevBoolean, AttrDataFormat.SCALAR, "calibration success",
             DispLevel.OPERATOR),
        ]

    def CalibrateAxis(self, axis):
        return self.springfield.calibrate(axis)

Now CalibrateAxis exists only on the motors whose hardware supports it. This is preferable to declaring the command everywhere and refusing it at call time: a command that is absent cannot be invoked by mistake, and it does not appear in any client’s command list.

The default implementation of GetAxisCommands() returns a copy of axis_commands, which is why the simple case needs no override at all. The two can also be combined: return the class member plus extra entries for the axes that deserve them.

Warning

The command set is built once, when the dynamic Tango interface of the element device is created. Changing what the override returns therefore needs a restart of the Pool server. In particular, none of the following rebuild it:

  • the Tango Init command, neither on the element nor on the Pool device;

  • reloading the controller library (relctrllib, ReloadControllerLib), which does reload your code but leaves the already-created devices with their existing commands.

Creating a new element does call the override for that element, so a freshly created axis picks up the current code without a restart.

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 try … except clause, if an expected return of a function: with a if … elif … else statement, etc)

  2. raise a proper exception (could be the same exception that has been caught) or, if in StateOne(), return the appropriate 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