Devices, Signals and their Backends#

The bluesky.run_engine.RunEngine facing interface is defined by the bluesky protocols that a Device implements, but to implement that interface ophyd-async uses some helper classes. This document details how those helper classes fit together to provide particular Device behavior.

Device and DeviceConnector#

        classDiagram
Device *-- DeviceConnector
Device : connect(mock)
Device <|-- EpicsDevice
Device <|-- TangoDevice
EpicsDevice *-- EpicsDeviceConnector
TangoDevice *-- TangoDeviceConnector
DeviceConnector <|-- EpicsDeviceConnector
DeviceConnector <|-- TangoDeviceConnector
    

The Device class is the base of all ophyd-async objects that are published to bluesky. It provides:

  • a Device.name read-only property to read it’s name

  • a Device.parent read-write property to read it’s parent Device if it exists

  • a Device.children to iterate through the Device attributes, yielding the (name, child) child Devices

  • a setattr override that detects whether the attribute is also a Device and sets its parent

  • a Device.set_name method to set its name and also set the names of its children using the parent name as a prefix

  • a Device.connect method that connects it and its children

All the above methods are concrete, but connect() calls out to a DeviceConnector to actually do the connection, only handling caching itself. This enables plug-in behavior on connect (like the introspection of child Attributes in Tango or PVI, or the special case for Signal we will see later).

A DeviceConnector provides the ability to:

The base DeviceConnector provides suitable methods for use with non-introspected Devices, but there are various control system specific connectors that handle filling annotations in declarative Devices.

Signal and SignalBackend#

        classDiagram
Device <|-- Signal
Signal : source
Signal <|-- SignalR
SignalR : read()
SignalR : subscribe()
SignalR : get_value()
Signal <|-- SignalW
SignalW : set()
SignalR <|-- SignalRW
SignalW <|-- SignalRW
SignalRW : locate()
Signal <|-- SignalX
SignalX : trigger()
Signal *-- SignalConnector
SignalConnector *-- SignalBackend
SignalBackend <|-- CaSignalConnector
SignalBackend <|-- PvaSignalConnector
SignalBackend <|-- TangoSignalConnector
    

If a Device with children is like a branch in a tree, a Signal is like a leaf. It has no children, but represents a single value or action in the control system. There are 4 types of signal:

These are all concrete classes, but delegate their actions to a SignalBackend:

class SignalBackend(Generic[SignalDatatypeT]):
    """A read/write/monitor backend for a Signals."""

    def __init__(self, datatype: type[SignalDatatypeT] | None):
        self.datatype = datatype

    @abstractmethod
    def source(self, name: str, read: bool) -> str:
        """Return source of signal.

        :param name: The name of the signal, which can be used or discarded.
        :param read: True if we want the source for reading, False if writing.
        """

    @abstractmethod
    async def connect(self, timeout: float):
        """Connect to underlying hardware."""

    @abstractmethod
    async def put(self, value: SignalDatatypeT | None, wait: bool):
        """Put a value to the PV, if wait then wait for completion."""

    @abstractmethod
    async def get_datakey(self, source: str) -> DataKey:
        """Metadata like source, dtype, shape, precision, units."""

    @abstractmethod
    async def get_reading(self) -> Reading[SignalDatatypeT]:
        """Return the current value, timestamp and severity."""

    @abstractmethod
    async def get_value(self) -> SignalDatatypeT:
        """Return the current value."""

    @abstractmethod
    async def get_setpoint(self) -> SignalDatatypeT:
        """Return the point that a signal was requested to move to."""

    @abstractmethod
    def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None:
        """Observe changes to the current value, timestamp and severity."""

Each control system implements its own concrete SignalBackend subclass with those methods filled in. It is these subclasses that take control system specific parameters like EPICS PV or Tango TRL. The instance is passed to Signal.__init__, which passes it to a generic SignalConnector.

At connect() time this SignalConnector does one of two things:

  • if mock==False then it calls SignalBackend.connect to connect it to the control system, and wires all the Signal methods to use it

  • if mock==True then it creates a MockSignalBackend for test purposes and wires all the Signal methods to use it

This means that to construct a Signal you need to do something like:

my_signal = SignalR(MyControlSystemBackend(int, cs_param="something"))

This is a little verbose, so instead we provide helpers like soft_signal_rw to make it a little shorter. The above might look like:

my_signal = my_cs_signal_r(int, "something")

“Standard” Device subclasses#

        classDiagram
Device <|-- StandardReadable
Device <|-- StandardDetector
Device <|-- StandardFlyer
    

There are also some Device subclasses that provide helpers for implementing particular protocols, namely: