Devices

A Device is a configurable and stateful (interface to) a hard- or software component, such as the interface to a robot or a software component that controls the phase and amplitude of a drive field.

The Device types form a type hierarchy with the root type

abstract type Device end

and feature generic interfaces and implementations. However, due to the nature of Julias multiple-dispatch, each Device can specialize and fully express its features at the cost of potentially diverging from its generic interface.

During Scanner construction, all its Devices listed in the initialization order parameter are also initialized. During the Device initialization, the Device can access its own parameters as given in the configuration file and all the Devices it depends on.

Structure

Each concrete Device implementation is a mutable composite type in the Device type hierarchy.

The fields of each Device can be grouped into three parts.

Common Device Fields

Every Device must have the fields deviceID, params, present and dependencies, as these are the fields used during automatic instantiation of a Scanner.

The deviceID is the name of a specific Device instance and corresponds to the name/key used in the Scanner.toml. The params field contains the user provided configuration parameters (see Device Parameter).

Lastly the present field denotes if a Device was succesfully initialized and the dependencies field contains a Dict containing all the dependent Devices of the current Device.

Device Parameter Field

Every Device must implement a type for the configuration parameters used during automatic instantiation.

These parameter types must inherit from the DeviceParams type and must be named like the Device type they belong to with the added suffix of Params:

mutable struct Example <: Device
    ...
end
struct ExampleParams <: DeviceParams
    parameter1::String
    parameter2::Int64
end

It is also possible to offer several variants of configuration parameters by providing a type hierarchy with an abstract root type <DeviceType>Params.

Internal Device Fields

And finally a Device can contain any number of "internal" fields, these are intended to be used to handle resources, such as connections, or any number of internal states. These fields need to be provided with a default value.

Initialization

Automatic initialization of a Device happens in two phases. First, the key/value pairs of the Scanner.toml for a given Device are passed to all potential DeviceParams as a constructor. If a fitting paramter type was found, the corresponding Device type is constructed with the parameter type set.

Afterwards, the _init function of the constructed Device is called, which executes user-defined code that should check the provied paramteres and prepare internal device fields. During this second process, a Device can access all the Devices it depends on.

Functions

Todo

Implementing New Devices

The following example shows how to implement a new Device. The chosen device is an interface to a temperature sensor, that can be queried via TCP/IP to return new temperature values. The made up sensor only has one channel/value.

To "simplify" configuration and showcase the DeviceParams, a user can either directly specify an IP or provided a number of IPs which the Device sequentially checks in the Scanner.toml.

Checking the existing Device tree shows that there are already a number of temperature sensor implementation and in particular, there is an abstract TemperatureSensor type. Therefore, the new Device should inherit from this type. Furthermore, the abstract type also defines a number of functions, which need to be implemented for the new sensor.

This gives the following starting point, which will evolve throughout the example:

mutable struct IPTempSensor <: TemperatureSensor
    ...
end
abstract type IPTempSensorParams <: DeviceParams end
struct IPTempSensorDirectParams <: IPTempSensorParams
    ip::String
    channelName::String
end
struct IPTempSensorSequentialParams <: IPTempSensorParams
    ips::Vector{String} # Note ip != ips, the names need to differ
    channelName::String
end

The start already shows the type hierarchy for two types of parameters. It is only important that the abstract type follows the rule of device name and params suffix.

Next one needs to implement the Params constructor, which takes a Dict as input. Here, MPIMeasurements.jl and Julia offers a few convience options. First, one can use the Base.@kwdef macro. This macro automatically defines keyword based constructors for a structure and it allows the definition of default values:

Base.@kwdef struct IPTempSensorDirectParams <: IPTempSensorParams
    ip::String
    channelName::String = "N/A"
end

This allows the structure to be constructed like this:

julia> params = IPTempSensorDirectParams(ip="192.168.1.100")

However, this is still not enough to simply construct a parameter object from the Dict, as the Dict contains a mapping of String to values. For the constructor, one needs a mapping of Symbols to values. Symbols in Julia are interned Strings. This conversion is something provided by MPIMeasurements.jl with the params_from_dict function:

Base.@kwdef struct IPTempSensorDirectParams <: IPTempSensorParams
    ip::String
    channelName::String = "N/A"
end
IPTempSensorDirectParams(dict::Dict) = params_from_dict(IPTempSensorDirectParams, dict)

The other parameter type requires the same changes. Now with the macro and provided function, the constructor for the parameter types is finished. In the Scanner.toml a user can now write something like:

[Devices.ipSensor]
deviceType = "IPTempSensor"
ip = "192.168.1.100"
channelName = "Drive Field Temp"

and MPIMeasurements.jl would find the IPTempSensorDirectParams in the first phase of Device construction.

Now the parameter type must be added to the Device type itself, together with all other mandatory fields. Here, MPIMeasurements.jl provides another macro, which given a parameter type name, adds all the mandatory fields to a struct:

Base.@kwdef mutable struct IPTempSensor <: TemperatureSensor
    @add_device_fields IPTempSensorParams
    conn::Union{Nothing, TCPSocket} = nothing
end

The structure was also provided with a keyword constructor and an added internal field to hold the necessary TCP connection. As this is an internal field, it needed to be provided with a default value.

The final step for automatic initialization is implementing the mandatory Device functions. The sensor itself does not have any required dependencies, which means those can be left empty:

neededDependencies(::IPTempSensor) = []
optionalDependencies(::IPTempSensor) = []

This only leaves the _init function, during which the parameters need to be checked and the TCP connection established. However, the Device has two different types of parameters. This can be handled with Julias multiple-dispatch:

function _init(sensor::IPTempSensor) 
    sensor.conn = establishConnection(sensor, sensor.params)
    if isnothing(sensor.conn)
        throw(ScannerConfigurationError("Could not connect to sensor"))
    end
end

function establishConnection(sensor::IPTempSensor, params::IPTempSensorDirectParams)
    conn = connect(params.ip)
    if # Test if connected to correct device 
        return conn
    else
        return nothing
    end
end

function establishConnection(sensor::IPTempSensor, params::IPTempSensorSequentialParams)
    for ip in params.ips
        conn = connect(params.ip)
        # Return if valid connection
    end
    return nothing
end

Now the sensor can be automatically initialized by MPIMeasurements.jl. In order to be used as a temperature sensor, however, the sensor-specific functions are still missing. As a last example, here are three of missing implemented functions:

numChannels(sensor::IPTempSensor) = 1
getChannelNames(sensor::IPTempSensor) = [sensor.params.channelName]
function getTemperature(sensor::IPTempSensor, channel::Int)
    if channel != 1
        throw(ArgumentError("IPTempSensor only has one channel, can not access channel $channel"))
    end
    write(sensor.conn, "TEMP?")
    reply = readline(sensor.conn)
    return parse(reply, Float64)
end

Once these are implemented, every Protocol, script and GUI that works with the other temperature sensors will seamlessly work with the new one. But it is also possible to specialise specific parts of Protocol to have unique behaviour for this particular sensor using multiple-dispatch.