Controllers#
FastCS provides three controller classes: Controller, ControllerVector, and
BaseController. This document explains what each does and when to use each.
Controller#
Controller is the primary building block for FastCS drivers. It can serve two roles:
Root controller: passed directly to the FastCS launcher. In this role, FastCS
will call its lifecycle hooks and run the scan tasks it creates on the event loop.
Sub controller: attached to a parent controller via add_sub_controller() or by
assigning it as an attribute. In this role, the sub controller’s lifecycle hooks
(connect, reconnect, initialise, disconnect) are not called automatically by
FastCS. The parent controller is responsible for calling them as part of its own
lifecycle, if required.
Lifecycle hooks#
Method |
Purpose |
|---|---|
|
Dynamically add attributes on startup, before the API is built |
|
Open connection to device |
|
Re-open connection after scan error |
|
Release device resources before shutdown |
Scan task behaviour#
When used as the root controller, FastCS collects all @scan methods and readable
attributes with update_period set, across the whole controller hierarchy to be run as
background tasks by FastCS. Scan tasks are gated on the _connected flag: if a scan
raises an exception, _connected is set to False and tasks pause until reconnect
sets it back to True.
from fastcs.controllers import Controller
from fastcs.attributes import AttrR, AttrRW
from fastcs.datatypes import Float, String
from fastcs.methods import scan
class TemperatureController(Controller):
temperature = AttrR(Float(units="degC"))
setpoint = AttrRW(Float(units="degC"))
async def connect(self):
self._client = await DeviceClient.connect(self._host, self._port)
self._connected = True
async def reconnect(self):
try:
self._client = await DeviceClient.connect(self._host, self._port)
self._connected = True
except Exception:
logger.error("Failed to reconnect")
async def disconnect(self):
await self._client.close()
@scan(period=1.0)
async def update_temperature(self):
value = await self._client.get_temperature()
await self.temperature.update(value)
Using Controller as a sub controller#
When a Controller is nested inside another, it organises the driver into logical
sections and its attributes are exposed under a prefixed path. If the sub
controller also has connection logic, the parent must invoke it explicitly:
class ChannelController(Controller):
value = AttrR(Float())
async def connect(self):
...
self._connected = True
class RootController(Controller):
channel: ChannelController
def __init__(self):
super().__init__()
self.channel = ChannelController()
async def connect(self):
await self.channel.connect()
self._connected = True
ControllerVector#
ControllerVector is a convenience wrapper for a set of controllers of the same type,
distinguished by a non-contiguous integer index rather than a string name.
Children are accessed via controller[<index>] instead of controller.<name>. The type
parameter Controller_T makes iteration type-safe when all children are the same
concrete type: iterating yields Controller_T directly, with no isinstance checks
needed. Mixing different subtypes is not prevented at runtime, but doing so widens the
inferred type to the common base, losing the type-safety benefit.
from fastcs.controllers import Controller, ControllerVector
class ChannelController(Controller):
value = AttrR(Float())
class RootController(Controller):
channels: ControllerVector[ChannelController]
def __init__(self, num_channels: int):
super().__init__()
self.channels = ControllerVector(
{i: ChannelController() for i in range(num_channels)}
)
async def connect(self):
for channel in self.channels.values():
await channel.connect()
self._connected = True
async def update_all(self):
for index, channel in self.channels.items():
value = await self._client.get_channel(index)
await channel.value.update(value)
Key properties of ControllerVector:
Indexes are integers and do not need to be contiguous (e.g.
{1: ..., 3: ..., 7: ...})All children must be
Controllerinstances of the same typeNamed sub controllers cannot be added to a
ControllerVectorChildren are exposed to transports with their integer index as the path component
When to use ControllerVector instead of Controller#
Use ControllerVector when:
The device has a set of identical channels, axes, or modules identified by number
You need to iterate over sub controllers and perform the same action on each
The number of instances may vary (e.g. determined at runtime during
initialise)
Use a plain Controller with named sub controllers when the sub controllers are
distinct components with different types or roles.
BaseController#
BaseController is the common base class for both Controller and ControllerVector.
It handles the creation and validation of attributes, scan methods, command methods, and
sub controllers, including type hint introspection and IO connection.
BaseController is public for use in type hints only. It should not be subclassed
directly when implementing a device driver. Use Controller or ControllerVector
instead.
from fastcs.controllers import BaseController
def configure_all(controller: BaseController) -> None:
"""Accept any controller type for generic operations."""
for name, attr in controller.attributes.items():
...