# Transports
This guide explains how transports connect FastCS controllers to external protocols, and how they use attribute callbacks to keep the protocol layer synchronized with attribute values.
## Transport Architecture
A transport connects a `ControllerAPI` to an external protocol. The `ControllerAPI` provides read-only access to:
- Attributes (`AttrR`, `AttrW`, `AttrRW`)
- Command methods (`@command`)
- Scan methods (`@scan`)
- Sub-controller APIs (hierarchical structure)
## Implementing a Transport
Subclass `Transport` and implement `connect()` and `serve()`:
```python
import asyncio
from dataclasses import dataclass, field
from typing import Any
from fastcs.controllers import ControllerAPI
from fastcs.transports.transport import Transport
@dataclass
class MyTransport(Transport):
"""Custom transport implementation."""
host: str = "localhost"
port: int = 9000
def connect(
self,
controller_api: ControllerAPI,
loop: asyncio.AbstractEventLoop,
) -> None:
"""Called during FastCS initialization.
Store the controller_api and set up your protocol server.
"""
self._controller_api = controller_api
self._loop = loop
self._server = MyProtocolServer(controller_api, self.host, self.port)
async def serve(self) -> None:
"""Called to start serving.
This runs as an async background task. It can block forever.
"""
await self._server.start()
@property
def context(self) -> dict[str, Any]:
"""Optional: Add variables to the interactive shell."""
return {"my_server": self._server}
```
## Working with ControllerAPI
The `ControllerAPI` provides access to the controller's attributes and methods. Use `walk_api()` to traverse the entire controller hierarchy and register all attributes and commands. Use pattern matching to handle different attribute types.
```python
for controller_api in root_controller_api.walk_api():
for name, attribute in controller_api.attributes.items():
match attribute:
case AttrRW():
protocol.create_read(name, attribute)
protocol.create_write(name, attribute)
case AttrR():
protocol.create_read(name, attribute)
case AttrW():
protocol.create_write(name, attribute)
for name, command in controller_api.command_methods.items():
protocol.create_command(name, command)
```
## Attributes
Transports use attribute callbacks to keep their protocol-specific representations synchronized with attribute values:
---
```{raw} html
:file: ../images/data-flow.excalidraw.svg
```
---
The diagram above shows the data flow between users, transports, attributes, and
hardware. The following table gives an overview of the data flow for the transport
layer.
| Callback | Registered with | Triggered By | Direction | Purpose |
|----------|-----------------|--------------|-----------|---------|
| On Update | `add_on_update_callback()` | `attr.update(value)` | Publish ↑ | Update protocol representation when attribute value changes |
| Sync Setpoint | `add_sync_setpoint_callback()` | `attr.put(value, sync_setpoint=True)` | Publish ↑ | Update transport's setpoint display without device communication |
| Update Datatype | `add_update_datatype_callback()` | `datatype` property changes | Publish ↑ | Update protocol metadata when datatype changes |
| Put | `attr.put(value)` | Transport receives user input | Put ↓ | Forward write requests from protocol to attribute |
### On Update Callbacks
Use `add_on_update_callback()` to update the protocol layer when an attribute's value changes.
```python
def create_read(name, attribute):
protocol_read = Protocol(name)
async def update_protocol_value(value):
protocol_read.post(value)
attribute.add_on_update_callback(update_protocol_value)
```
The callback receives the new value and should update the protocol-specific
representation (e.g., posting to a PV, updating a REST endpoint cache, publishing the
change to a subscriber).
### Update Datatype Callbacks
Use `add_update_datatype_callback()` to update protocol metadata when an attribute's datatype changes. This is useful for protocols that expose datatype metadata (like EPICS record fields).
```python
def create_read(name, attribute):
...
attribute.add_on_update_callback(update_protocol_value)
def update_protocol_metadata(datatype: DataType):
protocol_read.set_units(datatype.units)
protocol_read.set_limits(datatype.min, datatype.max)
attribute.add_update_datatype_callback(update_protocol_metadata)
```
The callback receives the new `DataType` instance and should update the protocol's metadata representation (e.g., EPICS record fields like `EGU`, `HOPR`, `LOPR`).
### Put
When the transport receives a write request from the protocol, call `await
attribute.put(value)` to forward it to the attribute. This triggers validation and
propagates the value to the device via the IO layer. The transport should also update
its own setpoint display directly rather than relying on the sync setpoint callback
being called.
```python
def create_write(name, attribute):
protocol_setpoint = Protocol(name)
async def handle_write(value):
protocol_setpoint.post(value)
await attribute.put(value)
```
### Sync Setpoint Callbacks
Use `add_sync_setpoint_callback()` to update the protocol layer's setpoint
representation when the transport receives a write request. This is called when
`AttrW.put` is called with `sync_setpoint=True`.
Each transport is responsible for updating its own setpoint display while actioning the
change and should not rely on its sync setpoint callback being called by the attribute,
nor should it call `AttrW.put` with `sync_setpoint=True`. Setpoints should not be synced
between transports in this case - this is intentional to show which transport the change
came from.
```python
def create_write(name, attribute):
...
async def update_setpoint_display(value):
protocol_setpoint.post(value)
attribute.add_sync_setpoint_callback(update_setpoint_display)
```
Sync setpoint callbacks are used in specific cases:
- When an attribute delegates to other attributes that actually communicate with the device
- During the first update of an `AttrRW`, to initialize the setpoint with the first readback value
## Commands
Transports can trigger commands, which connect directly to method calls rather than stateful attributes.
```python
def create_command(name, command):
protocol_command = Protocol(name)
async def handle_command():
await command.fn()
protocol_command.post()
```
## Usage
Transports are automatically registered when subclassing `Transport`:
```python
from fastcs.transports import Transport
@dataclass
class MyTransport(Transport):
# Automatically added to Transport.subclasses
pass
```
This allows the transport to be used in YAML configuration files.