Skip to content

How devices, presenters and views fit together

A component is a device, a presenter or a view. This page explains what each one is for, and how the session gives a component the things it needs.

Where each argument comes from

The session makes each component by calling its constructor and passing every argument by keyword. Here is where each parameter of this presenter gets its value:

class MotorPresenter:
    def __init__(self, name: str, *, devices: DeviceMapping, step: float = 1.0) -> None:
        self.name = name
        self.devices = devices
        self.step = step
Where the arguments of MotorPresenter come from
the declared namemotor_ctrlwhat the session holds,looked up by typeWhat exists before any component does. The settings (SessionConfig, Settings), the devices (DeviceMapping, DevicesOf[P]), the path provider, the catalog address, and the values the session's providers share.session fileor Declare(...)the default1.0MotorPresenter(...)namedevices: DeviceMappingstep: float = 1.0 by its typeif it gives stepotherwiseWhat exists before any component does. The settings (SessionConfig, Settings), the devices (DeviceMapping, DevicesOf[P]), the path provider, the catalog address, and the values the session's providers share.
the declared namemotor_ctrlwhat the session holds,looked up by typeWhat exists before any component does. The settings (SessionConfig, Settings), the devices (DeviceMapping, DevicesOf[P]), the path provider, the catalog address, and the values the session's providers share.session fileor Declare(...)the default1.0MotorPresenter(...)namedevices: DeviceMappingstep: float = 1.0 by its typeif it gives stepotherwiseWhat exists before any component does. The settings (SessionConfig, Settings), the devices (DeviceMapping, DevicesOf[P]), the path provider, the catalog address, and the values the session's providers share.

No code of yours chooses between a session file and the default. What the session holds by type includes the shared values of its providers, classes made only to share values (Share a value no component owns), and a Qt view also gets the main window as its parent (Frontends).

The session can't see types imported under if TYPE_CHECKING:

The session reads the annotations while the program runs, when a type imported only under if TYPE_CHECKING: doesn't exist, so it leaves the component out and logs a TypeError. Import those types normally; Limitations lists where this applies.

What arrives in setup

A constructor runs before the other components exist, so a component that needs another, or a value another shares, asks for it in an optional setup:

class MotorReadings:
    """The last position read from each motor."""

    def __init__(self) -> None:
        self.positions: dict[str, float] = {}


class RoiPresenter:
    def __init__(self, name: str) -> None:
        self.name = name

    def setup(self, readings: MotorReadings) -> None:
        self.readings = readings

Every setup runs once all presenters and views exist, filled by type, so a component can ask for one declared after it. The calls run in declaration order, though: a setup reading what another setup assigns sees it only if that component is declared first. An async def setup gets the component left out. When setup can't get what it asks for, the outcome depends on whose mistake it is:

When setup can't get what it asks for
setup raisesasks for a componentthat failed to buildasks for somethingnothing declaresasks for a componentof a later layerthe constructor asksfor a componentcomponent kept,listed under Not set upThe session logs the error and runs the component without what setup was going to give it.the build stopswith TypeErrorNo component failed. The mistake is in how the session is written, so you fix the session. The error says what to change, such as moving a constructor parameter to setup. The session logs the error and runs the component without what setup was going to give it. No component failed. The mistake is in how the session is written, so you fix the session. The error says what to change, such as moving a constructor parameter to setup.
setup raisesasks for a componentthat failed to buildasks for somethingnothing declaresasks for a componentof a later layerthe constructor asksfor a componentcomponent kept,listed under Not set upThe session logs the error and runs the component without what setup was going to give it.the build stopswith TypeErrorNo component failed. The mistake is in how the session is written, so you fix the session. The error says what to change, such as moving a constructor parameter to setup. The session logs the error and runs the component without what setup was going to give it. No component failed. The mistake is in how the session is written, so you fix the session. The error says what to change, such as moving a constructor parameter to setup.

The build checks a constructor that asks for a component, and a setup that asks for one of a later layer, before anything is built.

Sharing a value

A component offers a shared value to the others by marking a method with provides, and the method's return type is what the others ask for:

class MotorPresenter:
    def __init__(self, name: str) -> None:
        self.name = name
        self._readings = MotorReadings()

    @provides
    def readings(self) -> MotorReadings:
        return self._readings
How a shared value reaches another component
make MotorPresentercall readings()oncethe MotorReadingsit returnsRoiPresenter.setup(readings) right afterby its type
make MotorPresentercall readings()oncethe MotorReadingsit returnsRoiPresenter.setup(readings) right afterby its type

A type names one value, so two components sharing the same type stop the build with a TypeError. Share a value covers optional values too.

Using a protocol without importing redsun

The session checks a component only by its members' names and signatures, never by where a protocol came from. So a plugin can satisfy a redsun protocol, or copy its definition for its own type checker, without importing redsun. A copy works alone only when the types its members name do:

protocol names a copy works alone
Axis, Light ophyd-async and bluesky types only yes
DescribesAxes, DescribesLights AxisInfo or LightInfo, and Configuration, from redsun.utils.devices no
HasPlans PlanEntry no
HasActions ActionManager no
DescribesPlans PlanSpec and CallbackType no

A shared value is found by its exact type too, so asking for the RunEngine or for Deferrals, which applies a setting change during a plan without corrupting what the plan records, needs redsun's own classes.

Devices

A device is an ophyd-async device, a subclass of ophyd_async.core.Device; redsun adds nothing to the device layer, so the ophyd-async documentation covers signals and detectors. Your devices model your whole setup as a tree, and reaching the hardware is best left to a service, as Devices and services explains. Step through the cases to see which devices end up in the session:

How the session makes and connects a device

Every device is made from its declaration, then connected. Step through what can happen to it.

declarationAsDevice[MyCamera]makecls(name=..., **kwargs)The arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectall at once, up to 10 s eachin the sessionleft out constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.
declarationAsDevice[MyCamera]makecls(name=..., **kwargs)The arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectall at once, up to 10 s eachin the sessionleft out constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.

The usual case: the device is made, connects within 10 seconds, and takes its place in the session.

declarationAsDevice[MyCamera]makecls(name=..., **kwargs)The arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectall at once, up to 10 s eachin the sessionleft out constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.
declarationAsDevice[MyCamera]makecls(name=..., **kwargs)The arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectall at once, up to 10 s eachin the sessionleft out constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.

Its constructor raises, or it names a service with no prefix: the session logs it as Failed to build device and leaves it out.

declarationAsDevice[MyCamera]makeconstructor raisedThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectall at once, up to 10 s eachin the sessionleft outFailed to build device constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.
declarationAsDevice[MyCamera]makeconstructor raisedThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectall at once, up to 10 s eachin the sessionleft outFailed to build device constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.

It doesn't answer within 10 seconds: it is left out, and the summary lists it as not connected.

declarationAsDevice[MyCamera]makecls(name=..., **kwargs)The arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectno answer in 10 sin the sessionleft outcamera (device, not connected) constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.
declarationAsDevice[MyCamera]makecls(name=..., **kwargs)The arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectno answer in 10 sin the sessionleft outcamera (device, not connected) constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.

Declared with autoconnect=False, it skips the connection and stays in the session for your code to connect.

declarationAsDevice[MyCamera]makecls(name=..., **kwargs)The arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectall at once, up to 10 s eachin the sessionnot connectedleft out constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.
declarationAsDevice[MyCamera]makecls(name=..., **kwargs)The arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.connectall at once, up to 10 s eachin the sessionnot connectedleft out constructor raised,or no service prefixdidn't connectautoconnect=FalseThe arguments come from the session file or Declare(...), plus the prefix of the device's service and the path provider when the constructor takes them.

Making a device as cls(name=<name>, **kwargs) works for every ophyd-async device, including one whose first parameter is prefix, except one that takes name only by position (after a /), which is left out.

Connecting

A device declared with autoconnect=False stays unconnected for your code to connect when it chooses, so the build can't leave it out for missing hardware: a component decides what to do when the connection fails. See How to connect a device on demand.

Talking to a service

A device declared with service="stage_ioc" gets that service's prefix as its prefix, and is left out when the service isn't declared, didn't start or has no prefix.

Where a device writes

A device writes its own data files, in the format it or its service chooses. A constructor that takes path_provider gets the session's path provider, which puts every file of a session in one folder, named after the session, the day, the data key and the plan. How to choose where acquisition files go sets the folder and the names, and ADR 13 records why the device writes the data and not redsun.

Letting go of hardware

A service holding hardware, such as a camera, can let go of it and keep running if it offers a command for that as a process variable. When the user asks, your presenter triggers that command through the service's devices, which stay connected; the session has no standby step of its own.

Presenters

A presenter holds the session's application logic. It may run bluesky plans, react to the documents a run produces, move a device directly, or talk to another program. Because it never touches a widget, it works without a screen.

Any class can be a presenter if its constructor takes name as a keyword and its instances keep that name. It doesn't inherit anything from redsun, and it can't be an ophyd-async device.

Views

A view holds the widgets, and says where it wants to be shown with a placement, such as Dock("left").

The placement is what makes a class a view. Before the build, the frontend checks that it can show the placement and that the view is the right kind of object for it; Frontends covers the Qt rules, and How to place a view shows how to set one.

Signals and slots

Components talk to each other through signals and slots. They never call each other directly, unless one received the other in setup:

from psygnal import Signal

from redsun import slot


class MotorPresenter:
    sig_moved = Signal(str, float)


class MotorView(QWidget):
    @slot
    def refresh(self, motor: str, position: float) -> None: ...
A signal connected to a slot
MotorPresenter.sig_movedSignal(str, float)MotorView.refreshmarked with @slotRuns on the main thread, because a Qt widget may only be used from there. @slot(thread=...) picks another thread. the session connects them,in wire or the wiring sectionRuns on the main thread, because a Qt widget may only be used from there. @slot(thread=...) picks another thread.
MotorPresenter.sig_movedSignal(str, float)MotorView.refreshmarked with @slotRuns on the main thread, because a Qt widget may only be used from there. @slot(thread=...) picks another thread. the session connects them,in wire or the wiring sectionRuns on the main thread, because a Qt widget may only be used from there. @slot(thread=...) picks another thread.

Signal names start with sig_ by convention. A slot is marked with slot, may be async def, and is part of the component's public interface, since other code connects to it by name. Components never connect themselves; Wire components together shows wire and the wiring section. A presenter and a view written for each other can name the signals that reach their slots, and the session connects them with one pairing (Offer a pairing).

Cleaning up

If a component needs to clean up, give it a shutdown method, plain or async, and the session calls it when the session shuts down. A device can define one too, to leave its hardware in a safe state:

class MyLaser(StandardReadable):
    async def shutdown(self) -> None:
        await self.intensity.set(0)
The order components shut down in
presenters and viewsnewest firstdevicesEvery device that connected, and every device declared with autoconnect=False, since a component may have connected it. A device that didn't connect is left out of the session and never shut down, since it would write to hardware that never answered.services stop Every device that connected, and every device declared with autoconnect=False, since a component may have connected it. A device that didn't connect is left out of the session and never shut down, since it would write to hardware that never answered.
presenters and viewsnewest firstdevicesEvery device that connected, and every device declared with autoconnect=False, since a component may have connected it. A device that didn't connect is left out of the session and never shut down, since it would write to hardware that never answered.services stop Every device that connected, and every device declared with autoconnect=False, since a component may have connected it. A device that didn't connect is left out of the session and never shut down, since it would write to hardware that never answered.

The devices go after every presenter and view, which may still use them in their own shutdown, and before the services stop, so each device can still reach its service.

Dataclasses and pydantic models

A presenter can be a dataclass or a pydantic model, because the session passes every argument by keyword, and name may be anywhere in the signature:

@dataclass
class DataclassController:
    name: str
    step: float = 1.0


class ModelController(BaseModel):
    name: str
    step: float = 1.0
    sig_moved: ClassVar[Signal] = Signal(str)

On a pydantic model, a signal must be a ClassVar, since pydantic refuses a class attribute without an annotation. A value that setup assigns shouldn't be a field: use field(init=False) on a dataclass, or a private attribute on a model.

A class with __slots__ and a signal is left out

psygnal refers to the component weakly, so a class with __slots__ that owns a signal needs __weakref__ among its slots, or the component is never freed. The session leaves such a class out, and the error names the fix: add __weakref__ to the slots, or pass weakref_slot=True to a dataclass.

A Qt view can't be a pydantic model or a dataclass, because Qt needs its own QWidget.__init__ to run.

Two components of the same class

Two stages, or two copies of one plot, are normal, and each declaration makes its own component:

class MyApp(QtSession):
    stage_x: AsDevice[MyStage]
    stage_y: AsDevice[MyStage]

Your code reaches each one by its name, as self.stage_x. A component that needs every stage, however many there are, asks the session by what they can do: see Questions.