Wire components together¶
Components do not connect themselves. A presenter declares signals, a view declares signals and connectable methods, and the application says which signal reaches which method.
An application is declared in one of two ways, and every connection below is shown in both. Picking a tab switches every other tab on this page, and on any other page of this documentation, to the same form.
A container subclass declares its components as fields and its connections
by overriding wire.
A session built with AppContainer.from_config declares its components in
YAML sections and its connections in the wiring section.
Mark a method as connectable¶
Decorate it with slot. Only a marked method can be
connected, so marking one makes it public API: its name and its signature are
what other components are connected against.
from redsun.view import View
from redsun.virtual import slot
class ImageView(View):
@slot
def update_layers(self, readings: dict[str, Reading[Any]]) -> None:
for key, reading in readings.items():
self._layer(key).data = reading["value"]
Name a slot as you would any public method. slot accepts two options:
@slot(name="frames", thread="current")
def update_layers(self, readings: dict[str, Reading[Any]]) -> None: ...
nameis the port name used in a configuration file. It defaults to the method name without leading underscores, and exists so the method can be renamed without breaking a configuration.threadoverrides the thread the slot is delivered on.
Signals need no marker. Every public Signal attribute is
already a port.
Declare the connections¶
Every component is built by the time wire runs, and reads back as the
attribute it was declared under:
class MyApp(QtAppContainer, config="session.yaml"):
det_ctrl = declare_presenter(DetectorPresenter)
img_widget = declare_view(ImageView)
det_widget = declare_view(DetectorView)
def wire(self) -> None:
self.connect(self.det_ctrl.sig_new_data, self.img_widget.update_layers)
self.connect(self.det_widget.sig_property_changed, self.det_ctrl.configure)
Each attribute is typed as the class it was declared with, so a type checker
reads self.det_ctrl.sig_new_data as the signal it is. A renamed or
misspelled port is an error before the application runs; if it reaches the
build anyway, it is an AttributeError on the line that names it.
Each end is addressed as component.port:
schema_version: 1.0
frontend: pyqt
session: my-session
presenters:
det_ctrl:
plugin_name: my-plugin
plugin_id: detector
views:
img_widget:
plugin_name: my-plugin
plugin_id: image
det_widget:
plugin_name: my-plugin
plugin_id: detector
wiring:
- from: det_ctrl.sig_new_data
to: img_widget.update_layers
- from: det_widget.sig_property_changed
to: det_ctrl.configure
The component name is the key it was declared under. A signal port is the signal's attribute name; a slot port is the name the slot declares.
Fan-in is another line, not another concept: a second producer of frames reaches the same viewer by adding one connection.
Both forms end in the same call, so a container may use both. wire runs first,
then the wiring section of its configuration file.
Connect a coroutine¶
An async def method is a slot like any other. Mark it and connect it; nothing
else changes.
class MotorPresenter(Presenter):
@slot
async def move(self, motor: str, position: float) -> None:
await self.devices[motor].set(position)
Dispatch differs from a plain method in three ways:
- the coroutine runs on redsun's shared event loop, not on the thread that emitted;
- the emitter does not wait for it. The emission returns as soon as the coroutine is scheduled;
- an exception inside it is logged on the
redsunlogger instead of propagating back to the emitter, and later emissions keep being delivered.
If the last two matter, keep a sync method that owns the call and connect that instead:
@slot
def move(self, motor: str, position: float) -> None:
run_coro(self.move_async(motor, position))
Warning
The async backend must be installed before the wiring phase, or psygnal
rejects the coroutine at connect. QtAppContainer.build calls
set_async_backend for you; a plain
AppContainer does not, so call it yourself before build.
Address a signal group¶
A component whose signals live in a SignalGroup
exposes each member as a port, under the member name. The group attribute
itself is not a port.
class FrameSignals(SignalGroup, strict=True):
median = Signal(object)
filtered = Signal(object)
class MedianPresenter(Presenter):
def __init__(self, name: str, devices: Mapping[str, Device], /) -> None:
super().__init__(name, devices)
self.frames = FrameSignals(instance=self)
Reach the member through the group attribute:
Warning
Pass instance=self when building the group. Without it the container
cannot tell which component owns the signal, and the wiring report names
the group instead of the component.
Group members and plain signals share one port namespace, so a member named
after an existing public signal on the same class raises
WiringError when the ports are read.
Choose the thread a slot runs on¶
Thread affinity belongs to the component, not to the connection. A class declares it once:
from typing import ClassVar
from redsun.virtual import SlotThread
class MyView(View):
__redsun_slot_thread__: ClassVar[SlotThread] = "main"
Every slot on that class is then delivered on the main thread. @slot(thread=...)
overrides it for one method, and connect(..., thread=...) overrides both.
QtView already declares "main", so a Qt widget's slots need nothing.
Observe a device signal¶
Device signals are ophyd-async, not psygnal, so connect does not take them.
subscribe does, and gives the
same guarantees:
class StorageView(QtView):
@slot
def update_base_dir(self, reading: dict[str, Reading[str]]) -> None:
self._edit.setText(next(iter(reading.values()))["value"])
class MyApp(QtAppContainer):
def wire(self) -> None:
provider = self.virtual_container.require(PATH_PROVIDER)
self.virtual_container.subscribe(
provider.signals.base_dir, self.storage_widget.update_base_dir
)
The slot still has to be marked, the thread affinity still comes from the component, and the subscription is released at shutdown. That last one matters more here than for signals: ophyd-async releases a subscription by identity, so whoever subscribed has to keep the exact callback object to undo it.
Note
ophyd-async needs a running event loop to subscribe, and wire runs on the
main thread. subscribe handles that for you.
Inspect what is connected¶
for link in app.virtual_container.connections:
print(link)
for record in app.virtual_container.subscriptions:
print(record)
det_ctrl.sig_new_data -> img_widget.update_layers [thread=main]
det_widget.sig_property_changed -> det_ctrl.configure
base_dir ~> storage_widget.update_base_dir [thread=main]
-> is a signal connection, ~> a device subscription.
ports answers the other half, what a component
offers:
Both are recorded, so AppContainer.shutdown releases them.
Find what is not connected¶
A wrong port name fails at build. A connection you forgot to write fails
nowhere: a signal simply emits into nothing. unconnected is what finds it,
by subtracting the recorded links from what every built component offers.
It is a plain Unconnected record, so a script
can assert on it instead of reading it:
Not every entry is a defect. A component may legitimately offer more than one application uses; the report says what is unused, not what is wrong.
Read a failure¶
Every way of getting a connection wrong fails at build, naming both ends.
| Message | Cause |
|---|---|
AttributeError: 'DetectorPresenter' object has no attribute 'sig_typo' |
the signal was renamed or misspelled |
... is not connectable; mark it with the 'slot' decorator |
the method exists but has no @slot |
cannot connect a.sig -> b.port: Cannot connect slot ... |
psygnal rejected the signature: wrong argument count, or wrong type against a signal that names one |
| Message | Cause |
|---|---|
'a.sig' names component 'a', which was not built. Built: ... |
the file names a component the session did not load |
'a' exposes no signal named 'sig'. Its signal ports: ... |
the signal name is wrong |
'a' exposes no slot named 'port'. Its slot ports: ... |
the port name is wrong, or the method was never marked |
'a.b.c' is not a port path; expected 'component.port' |
malformed path |
wiring entry 0 must be a mapping with exactly the keys 'from' and 'to' |
a rule is missing a key or carries an extra one |
cannot connect a.sig -> b.port: Cannot connect slot ... |
psygnal rejected the signature |