diff --git a/docs/explanations/controllers.md b/docs/explanations/controllers.md new file mode 100644 index 000000000..2378144d2 --- /dev/null +++ b/docs/explanations/controllers.md @@ -0,0 +1,171 @@ +# 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 | +|---|---| +| `initialise` | Dynamically add attributes on startup, before the API is built | +| `connect` | Open connection to device | +| `reconnect` | Re-open connection after scan error | +| `disconnect` | 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`. + +```python +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: + +```python +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[]` instead of `controller.`. 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. + +```python +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 `Controller` instances of the same type +- Named sub controllers cannot be added to a `ControllerVector` +- Children 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. + +```python +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(): + ... +``` diff --git a/docs/explanations/datatypes.md b/docs/explanations/datatypes.md new file mode 100644 index 000000000..c614deb1d --- /dev/null +++ b/docs/explanations/datatypes.md @@ -0,0 +1,260 @@ +# Datatypes + +FastCS uses a datatype system to map Python types to attributes with additional +metadata for validation, serialization, and transport handling. + +## Supported Types + +FastCS defines `DType` as the union of supported Python types: + +:::{literalinclude} ../../src/fastcs/datatypes/datatype.py +:start-at: "DType = (" +:end-at: ")" +::: + +Each has a corresponding `DataType` class. + +## Scalar Datatypes + +### Int and Float + +Both inherit from `_Numeric`, which adds support for bounds and alarm limits: + +:::{literalinclude} ../../src/fastcs/datatypes/_numeric.py +:start-at: "@dataclass(frozen=True)" +:end-at: "max_alarm:" +::: + +### Bool + +Maps to Python `bool`. Initial value is `False`. + +:::{literalinclude} ../../src/fastcs/datatypes/bool.py +:pyobject: Bool +::: + +### String + +Maps to Python `str`. Has an optional `length` field that truncates values during validation. It is also used as a hint by some transports to configure the size of string records (e.g. EPICS CA string waveform records). + +:::{literalinclude} ../../src/fastcs/datatypes/string.py +:pyobject: String +::: + +## Enum Datatype + +Wraps a Python `enum.Enum` class: + +:::{literalinclude} ../../src/fastcs/datatypes/enum.py +:pyobject: Enum +::: + +The `Enum` datatype provides helper properties: + +- `members`: List of enum values +- `names`: List of enum member names +- `index_of(value)`: Get the index of a value in the members list + +:::{note} +FastCS uses enum **member names** (not values) when exposing choices to transports and +PVI. This means member names are the user-friendly UI strings while values are the +strings sent to the device: + +```python +class DetectorStatus(StrEnum): + Idle = "IDLE_STATE" + Running = "RUNNING_STATE" + Error = "ERROR_STATE" +``` + +Clients will see the choices as `["Idle", "Running", "Error"]`. + +For UI strings with spaces, use the functional `enum.Enum` API with a dict: + +```python +import enum +from fastcs.datatypes import Enum + +DetectorStatus = Enum(enum.Enum("DetectorStatus", {"Run Finished": "RUN_FINISHED", "In Progress": "IN_PROGRESS"})) +``` + +Clients will see the choices as `["Run Finished", "In Progress"]`. +::: + +## Array Datatypes + +### Waveform + +For homogeneous numpy arrays (spectra, images): + +:::{literalinclude} ../../src/fastcs/datatypes/waveform.py +:pyobject: Waveform +::: + +Validation ensures the array fits within the declared shape and has the correct dtype. + +### Table + +For structured numpy arrays with named columns: + +:::{literalinclude} ../../src/fastcs/datatypes/table.py +:pyobject: Table +::: + +The `structured_dtype` field is a list of `(name, dtype)` tuples following +numpy's structured array conventions. + +## Validation + +### Built-in Numeric Validation + +`Int` and `Float` datatypes support min/max limits and alarm thresholds: + +```python +from fastcs.attributes import AttrRW +from fastcs.datatypes import Int, Float + +# Integer with bounds +count = AttrRW(Int(min=0, max=100)) + +# Float with units and alarm limits +temperature = AttrRW(Float( + units="degC", + min=-273.15, # Absolute minimum + max=1000.0, # Absolute maximum + min_alarm=-50.0, # Warning below this + max_alarm=200.0, # Warning above this +)) +``` + +#### Validation Behavior + +```python +temp = Float(min=0.0, max=100.0) + +temp.validate(50.0) # Returns 50.0 +temp.validate(-10.0) # Raises ValueError: "Value -10.0 is less than minimum 0.0" +temp.validate(150.0) # Raises ValueError: "Value 150.0 is greater than maximum 100.0" +``` + +### String Length + +Limit the display length of strings: + +```python +from fastcs.datatypes import String + +# Limit display to 40 characters +status = AttrR(String(length=40)) +``` + +:::{note} +The `length` parameter truncates values during validation and is also used by some +transports to configure their records, for example the EPICS CA transport uses it to +set the length of string waveform records. +::: + +### Type Coercion + +All datatypes automatically coerce compatible types: + +```python +from fastcs.datatypes import Int, Float + +int_type = Int() +int_type.validate("42") # Returns 42 (str -> int) +int_type.validate(3.7) # Returns 3 (float -> int, truncated) + +float_type = Float() +float_type.validate("3.14") # Returns 3.14 (str -> float) +float_type.validate(42) # Returns 42.0 (int -> float) +``` + +### When Validation Runs + +Validation runs automatically when: + +1. **Attribute update**: `await attr.update(value)` validates before storing +2. **Put request**: `await attr.put(value)` validates before sending to device +3. **Initial value**: Values passed to `initial_value` are validated on creation + +```python +from fastcs.attributes import AttrRW +from fastcs.datatypes import Int + +attr = AttrRW(Int(min=0, max=10), initial_value=5) + +# Updates are validated +await attr.update(7) # OK +await attr.update(15) # Raises ValueError + +# Puts are validated +await attr.put(3) # OK +await attr.put(-1) # Raises ValueError +``` + +## Transport Handling + +Transports are responsible for serializing datatypes appropriately for their protocol. +Each transport must handle all supported datatypes. The datatype's `dtype` property +and class type are used to determine serialization: + +- Scalars (`Int`, `Float`, `Bool`, `String`) serialize directly +- `Enum` values are typically serialized as integers (index) or strings (name) +- `Waveform` and `Table` arrays are serialized as lists or protocol-specific array types + +## Creating Custom Datatypes + +All datatypes inherit from `DataType[DType_T]`, a generic frozen dataclass that defines +the interface for type handling: + +:::{literalinclude} ../../src/fastcs/datatypes/datatype.py +:start-at: "@dataclass(frozen=True)" +:end-at: "raise NotImplementedError()" +::: + +### Required Properties + +To create a custom datatype, subclass `DataType` or one of the existing datatypes and +implement the required properties: + +**`dtype`**: Returns the underlying Python type. This is used for type coercion in +`validate()` and for transport serialization. + +**`initial_value`**: Returns the default value used when an attribute is created +without an explicit initial value. + +### Overriding `validate()` + +The base `validate()` implementation attempts to cast incoming values to the target type: + +:::{literalinclude} ../../src/fastcs/datatypes/datatype.py +:pyobject: DataType.validate +::: + +Subclasses can override this to add validation logic. The pattern is + +1. Coerce input to help type casting succeed - e.g. `Waveform` calls `numpy.asarray(...)` +2. Call `super().validate(value)` to call parent implementation and perform the type cast +3. Perform any additional validation such as checking limits - e.g. `_Numeric` adds min/max validation: + +:::{literalinclude} ../../src/fastcs/datatypes/_numeric.py +:pyobject: _Numeric.validate +::: + +### Overriding `equal()` + +The `equal()` method is used by the `always` flag in attribute callbacks to determine +if a value has changed. The default uses Python's `==` operator, but array types +override this to use `numpy.array_equal()`: + +:::{literalinclude} ../../src/fastcs/datatypes/waveform.py +:pyobject: Waveform.equal +::: + +### Transport Compatibility + +When creating a new datatype, existing transports will need to be updated to handle it, +unless the datatype inherits from a supported type. In the latter case, the transport +will use the parent class handling, while the custom datatype can add validation or +other behaviour on top. diff --git a/docs/explanations/transports.md b/docs/explanations/transports.md new file mode 100644 index 000000000..bf34a2fcc --- /dev/null +++ b/docs/explanations/transports.md @@ -0,0 +1,213 @@ +# 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. diff --git a/docs/explanations/what-is-fastcs.md b/docs/explanations/what-is-fastcs.md new file mode 100644 index 000000000..501a54284 --- /dev/null +++ b/docs/explanations/what-is-fastcs.md @@ -0,0 +1,98 @@ +# What is FastCS? + +FastCS is a Python framework for building device drivers for scientific instruments. +It separates the logic of communicating with a device from the control system used to +expose it, so the same driver works with EPICS (CA or PVA), Tango, REST, and GraphQL +without modification. + +--- + +
+ +```{raw} html +:file: ../images/overview.excalidraw.svg +``` + +
+ +--- + +## Architecture + +A FastCS application has three layers: + +**Controller** - a Python class that models the device. It holds attributes and +commands, implements connection logic, and creates periodic polling tasks. The +controller can create `AttributeIO`s to handle `update` and `send` operations between +attributes and the device. + +**Attributes and commands** - typed values (`AttrR`, `AttrW`, `AttrRW`) and callable +actions (`@command`) declared on the controller. Attributes represent the device's +readable and writable parameters and carry metadata such as units, limits, and alarm +thresholds. Commands are actions that can be triggered to run. + +**Transport** - the protocol layer that exposes the controller's attributes and commands +to a control system. FastCS provides transports for EPICS CA, EPICS PVA, Tango, REST, +and GraphQL. + +--- + +
+ +```{raw} html +:file: ../images/data-flow.excalidraw.svg +``` + +
+ +--- + +Sub-controllers let you model hierarchical devices — a sub-controller's attributes are +exposed under a prefixed path, and a set of identical sub-controllers can be grouped +using `ControllerVector`. See [Controllers](controllers.md) for details. + +## Writing a driver + +To create a driver, subclass `Controller`, declare attributes and commands as typed +class members, and implement lifecycle hooks (`connect`, `reconnect`, `disconnect`) and +periodic scan tasks. The [tutorials](../tutorials.md) walk through a complete example. + +## Deploying as an application + +Passing the controller class to `launch()` generates a standard CLI with `run` and +`schema` commands. The `run` command takes a YAML configuration file that specifies +both the controller's settings and which transports to start. Swapping or adding +transports requires only a config change, not a code change. See +[Launching the framework](../how-to/launch-framework.md) for details. + +## Benefits + +**Simple API** — a driver is a plain Python class with typed class-variable attributes. +There is no boilerplate or protocol-specific code in the driver itself. + +**Control-system-agnostic testing** — controllers have no dependency on EPICS, Tango, +or any other control system and can be unit-tested as ordinary Python objects. Testing +device logic does not require a running IOC or device server. + +**Multiple transports from a single driver** — the same controller can serve EPICS CA, +EPICS PVA, Tango, REST, and GraphQL simultaneously or in any combination. + +**Automatic reconnection** — if a scan task raises an exception, FastCS pauses all +tasks and calls `reconnect()` automatically, resuming once the connection is restored. + +**Auto-generated OPI screens** — EPICS transports generate CSS-Phoebus screen files +from the controller's attribute metadata, with no additional effort from the driver +author. + +**Interactive shell** — FastCS starts an IPython shell alongside the running driver, +giving direct access to the live controller instance for inspection and ad-hoc commands +without restarting. + +**Structured logging and per-attribute tracing** — FastCS uses +[loguru](https://loguru.readthedocs.io/) for structured logging. The `Tracer` mixin +adds TRACE-level logging to individual attributes, allowing fine-grained visibility +into specific values without increasing the verbosity of the whole driver. + +**Graylog integration** — the generated CLI accepts a `--graylog-endpoint` option that +forwards all log output to a Graylog instance, with support for static and +environment-variable-sourced fields for log enrichment. diff --git a/docs/how-to/arrange-epics-screens.md b/docs/how-to/arrange-epics-screens.md new file mode 100644 index 000000000..d7f1303f2 --- /dev/null +++ b/docs/how-to/arrange-epics-screens.md @@ -0,0 +1,84 @@ +# Arrange EPICS Screens with Groups and Sub-Controllers + +This guide shows how to use `group` on attributes and commands to organise widgets into +labelled boxes on a screen, and how splitting a device into sub-controllers creates +navigable sub-screens for larger devices. + +Both the CA and PVA EPICS transports generate screens from the same controller structure, +so the techniques shown here apply to both. + +## Group Attributes and Commands into Boxes + +By default, all attributes and commands on a controller appear as a flat list of widgets +on the generated screen. Assigning a `group` string places them together inside a labelled +box. + +```python +from fastcs.attributes import AttrR, AttrRW +from fastcs.controllers import Controller +from fastcs.datatypes import Float, Int +from fastcs.methods import command + + +class PowerSupplyController(Controller): + voltage = AttrRW(Float(), group="Output") + current = AttrRW(Float(), group="Output") + power = AttrR(Float(), group="Output") + + temperature = AttrR(Float(), group="Status") + fault_code = AttrR(Int(), group="Status") + + @command(group="Actions") + async def reset_faults(self) -> None: + ... + + @command(group="Actions") + async def enable_output(self) -> None: + ... +``` + +The generated screen will show three boxes — **Output**, **Status**, and **Actions** — +each containing only the widgets assigned to that group. Attributes and commands with no +`group` are placed outside any box, directly on the screen. + +## Use Sub-Controllers to Create Sub-Screens + +For devices with many attributes, a single flat screen becomes unwieldy. Splitting +functionality across multiple controllers, connected with `add_sub_controller()`, causes +the transport to generate a top-level screen with navigation links to per-sub-controller +sub-screens. + +```python +from fastcs.attributes import AttrR, AttrRW +from fastcs.controllers import Controller +from fastcs.datatypes import Float, Int +from fastcs.methods import command + + +class ChannelController(Controller): + voltage = AttrRW(Float(), group="Output") + current = AttrRW(Float(), group="Output") + temperature = AttrR(Float(), group="Status") + + @command(group="Actions") + async def enable(self) -> None: + ... + + +class MultiChannelPSU(Controller): + total_power = AttrR(Float()) + + @command() + async def disable_all(self) -> None: + ... + + def __init__(self, num_channels: int) -> None: + super().__init__() + for i in range(1, num_channels + 1): + self.add_sub_controller(f"Ch{i:02d}", ChannelController()) +``` + +The top-level screen for `MultiChannelPSU` shows `TotalPower` and `DisableAll` alongside +buttons labelled **Ch01**, **Ch02**, … that each open the sub-screen for that channel. +Each channel sub-screen then shows the **Output**, **Status**, and **Actions** boxes +defined on `ChannelController`. diff --git a/docs/how-to/launch-framework.md b/docs/how-to/launch-framework.md new file mode 100644 index 000000000..e3f210fb8 --- /dev/null +++ b/docs/how-to/launch-framework.md @@ -0,0 +1,177 @@ +# Use the Launch Framework for CLI Applications + +This guide shows how to use `launch()` to create deployable FastCS drivers with +automatic CLI generation and YAML configuration. + +## Basic Setup + +The `launch()` function generates a CLI from the controller's type hints: + +```python +from fastcs.controllers import Controller +from fastcs.launch import launch + +class MyController(Controller): + pass + +if __name__ == "__main__": + launch(MyController) +``` + +This creates a CLI with: + +- `--version` - Display version information +- `schema` - Output JSON schema for configuration +- `run ` - Start the controller with a YAML config file + +## Adding Configuration Options + +It is recommended to use a dataclass or Pydantic model for the controller's +configuration, as these provide schema generation and IDE support. The `launch()` +function checks that `__init__` has at most one argument (besides `self`) and that the +argument has a type hint, which is required to infer the schema: + +```python +from dataclasses import dataclass + +from fastcs.controllers import Controller +from fastcs.launch import launch + +@dataclass +class DeviceSettings: + ip_address: str + port: int = 25565 + timeout: float = 5.0 + +class DeviceController(Controller): + def __init__(self, settings: DeviceSettings): + super().__init__() + self.settings = settings + +if __name__ == "__main__": + launch(DeviceController, version="1.0.0") +``` + +## YAML Configuration Files + +Create a YAML configuration file matching the schema: + +```yaml +# device_config.yaml +controller: + ip_address: "192.168.1.100" + port: 25565 + timeout: 10.0 + +transport: + - epicsca: + pv_prefix: "DEVICE" +``` + +Run with: + +```bash +python my_driver.py run device_config.yaml +``` + +## Schema Generation + +Generate JSON schema for the configuration yaml: + +```bash +python my_driver.py schema > schema.json +``` + +Use this schema for IDE autocompletion in YAML files: + +```yaml +# yaml-language-server: $schema=schema.json +controller: + ip_address: "192.168.1.100" + # ... IDE will provide autocompletion +``` + +## Transport Configuration + +Transports are configured in the `transport` section as a list: + +```yaml +transport: + # EPICS Channel Access + - epicsca: + pv_prefix: "DEVICE" + gui: + output_path: "opis/device.bob" + title: "Device Control" + + # REST API + - rest: + host: "0.0.0.0" + port: 8080 + + # GraphQL + - graphql: + host: "localhost" + port: 8081 +``` + +## Logging Options + +The `run` command includes logging options: + +```bash +# Set log level +python my_driver.py run config.yaml --log-level debug + +# Send logs to Graylog +python my_driver.py run config.yaml \ + --graylog-endpoint "graylog.example.com:12201" \ + --graylog-static-fields "app=my_driver,env=prod" +``` + +Available log levels: `TRACE`, `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` + +## Version Information + +Pass a version string to display the driver version: + +```python +launch(DeviceController, version="1.2.3") +``` + +```bash +$ python my_driver.py --version +DeviceController: 1.2.3 +FastCS: 0.12.0 +``` + +## Constraints + +The `launch()` function requires: + +1. Controller `__init__` must have at most 2 arguments (including `self`) +2. If a configuration argument exists, it must have a type hint + +Using a dataclass or Pydantic model is recommended for the configuration type, as it enables JSON schema generation. Other type-hinted types will work, but will not produce a useful schema. + +```python +# Valid - no config +class SimpleController(Controller): + def __init__(self): + super().__init__() + +# Valid - with config +class ConfiguredController(Controller): + def __init__(self, settings: MySettings): + super().__init__() + +# Invalid - missing type hint +class BadController(Controller): + def __init__(self, settings): # Error: no type hint + super().__init__() + +# Invalid - too many arguments +class TooManyArgs(Controller): + def __init__(self, settings: MySettings, extra: str): # Error + super().__init__() +``` diff --git a/docs/how-to/multiple-transports.md b/docs/how-to/multiple-transports.md new file mode 100644 index 000000000..626340096 --- /dev/null +++ b/docs/how-to/multiple-transports.md @@ -0,0 +1,161 @@ +# Run Multiple Transports Simultaneously + +This guide shows how to expose a fastcs driver through multiple protocols at once. + +## Basic Setup + +Pass a list of transports to `FastCS`: + +```python +from fastcs.control_system import FastCS +from fastcs.transports import ( + EpicsCATransport, + EpicsIOCOptions, + GraphQLTransport, + RestTransport, +) + +controller = MyController() + +fastcs = FastCS( + controller, + [ + EpicsCATransport(epicsca=EpicsIOCOptions(pv_prefix="DEVICE")), + RestTransport(), + GraphQLTransport(), + ] +) +fastcs.run() +``` + +All transports run concurrently, exposing the same controller API. + +## Available Transports + +| Transport | Protocol | Install Extra | Primary Use Case | +|-----------|----------|---------------|------------------| +| `EpicsCATransport` | EPICS Channel Access | `fastcs[epicsca]` | Control system integration | +| `EpicsPVATransport` | EPICS PV Access | `fastcs[epicspva]` | Modern EPICS with structured data | +| `TangoTransport` | Tango | `fastcs[tango]` | Tango control system | +| `RestTransport` | HTTP REST | `fastcs[rest]` | Web applications, debugging | +| `GraphQLTransport` | GraphQL | `fastcs[graphql]` | Flexible queries, web clients | + +Install extras as needed: + +```bash +pip install "fastcs[epicsca,rest,graphql]" +``` + +## Transport Configuration + +Each transport has its own options: + +### EPICS Channel Access + +```python +from pathlib import Path +from fastcs.transports import ( + EpicsCATransport, + EpicsDocsOptions, + EpicsGUIOptions, + EpicsIOCOptions, +) + +epics_ca = EpicsCATransport( + epicsca=EpicsIOCOptions(pv_prefix="DEVICE"), + gui=EpicsGUIOptions( + output_path=Path(".") / "device.bob", + title="Device Control", + ), + docs=EpicsDocsOptions( + output_path=Path(".") / "device.csv", + ), +) +``` + +### EPICS PV Access + +```python +from fastcs.transports import EpicsPVATransport, EpicsIOCOptions + +epics_pva = EpicsPVATransport( + epicspva=EpicsIOCOptions(pv_prefix="DEVICE"), +) +``` + +### REST + +```python +from fastcs.transports import RestTransport +from fastcs.transports.rest import RestServerOptions + +rest = RestTransport( + rest=RestServerOptions( + host="0.0.0.0", + port=8080, + log_level="info", + ) +) +``` + +### GraphQL + +```python +from fastcs.transports import GraphQLTransport +from fastcs.transports.graphql import GraphQLServerOptions + +graphql = GraphQLTransport( + graphql=GraphQLServerOptions( + host="localhost", + port=8081, + ) +) +``` + +### Tango + +```python +from fastcs.transports import TangoTransport, TangoDSROptions + +tango = TangoTransport( + tango=TangoDSROptions( + device_name="test/device/1", + ), +) +``` + +## EPICS CA + PVA Together + +Run both EPICS protocols simultaneously: + +```python +from pathlib import Path + +from fastcs.transports import ( + EpicsCATransport, + EpicsGUIOptions, + EpicsIOCOptions, + EpicsPVATransport, +) + +fastcs = FastCS( + controller, + [ + EpicsCATransport( + epicsca=EpicsIOCOptions(pv_prefix="DEVICE"), + gui=EpicsGUIOptions(output_path=Path(".") / "device.bob"), + ), + EpicsPVATransport( + epicspva=EpicsIOCOptions(pv_prefix="DEVICE"), + ), + ] +) +``` + +Both transports share the same PV prefix and expose identical PVs. + +## YAML Configuration + +When using the `launch()` framework, configure transports in YAML. + +See [Using the Launch Framework](launch-framework.md) for details. diff --git a/docs/how-to/table-waveform-data.md b/docs/how-to/table-waveform-data.md new file mode 100644 index 000000000..d7a7572bc --- /dev/null +++ b/docs/how-to/table-waveform-data.md @@ -0,0 +1,154 @@ +# Work with Table and Waveform Data + +This guide shows how to use `Waveform` and `Table` datatypes for array-based data. + +## Waveform - Homogeneous Arrays + +Use `Waveform` for numpy arrays of a single data type (spectra, time series, images). + +### Basic 1D Waveform + +```python +import numpy as np + +from fastcs.attributes import AttrR, AttrRW +from fastcs.controllers import Controller +from fastcs.datatypes import Waveform + +class SpectrumController(Controller): + # 1D array of 1000 float64 values + spectrum: AttrR[np.ndarray] = AttrR(Waveform(np.float64, shape=(1000,))) + + # Writable waveform + setpoints: AttrRW[np.ndarray] = AttrRW(Waveform(np.float64, shape=(100,))) +``` + +### 2D Waveform (Images) + +```python +class CameraController(Controller): + # 2D array for images (max 1024x1024 uint16) + image: AttrR[np.ndarray] = AttrR(Waveform(np.uint16, shape=(1024, 1024))) + + # Smaller region of interest + roi: AttrRW[np.ndarray] = AttrRW(Waveform(np.uint16, shape=(256, 256))) +``` + +### Waveform Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `array_dtype` | `DTypeLike` | (required) | Numpy dtype (`np.float64`, `np.int32`, etc.) | +| `shape` | `tuple[int, ...]` | `(2000,)` | Maximum array dimensions | + +### Updating Waveforms + +```python +from fastcs.methods import scan + +class SpectrumController(Controller): + spectrum: AttrR[np.ndarray] = AttrR(Waveform(np.float64, shape=(1000,))) + + @scan(period=0.1) + async def read_spectrum(self): + # Get data from device (e.g., numpy array) + data = await self.device.get_spectrum() + + # Update the attribute + await self.spectrum.update(data) +``` + +### Shape Validation + +Waveforms validate that data fits within the declared shape: + +```python +wave = Waveform(np.float64, shape=(100,)) + +# OK - fits within shape +wave.validate(np.array([1.0, 2.0, 3.0])) + +# Error - exceeds maximum shape +wave.validate(np.arange(200)) # ValueError: shape (200,) exceeds maximum (100,) +``` + +## Table - Structured Arrays + +Use `Table` for tabular data with named columns of different types. + +### Basic Table + +```python +import numpy as np + +from fastcs.attributes import AttrR +from fastcs.controllers import Controller +from fastcs.datatypes import Table + +class MeasurementController(Controller): + # Table with columns: name (string), value (float), valid (bool) + results: AttrR[np.ndarray] = AttrR(Table([ + ("name", "S32"), # 32-character string + ("value", np.float64), + ("valid", np.bool_), + ])) +``` + +### Table Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `structured_dtype` | `list[tuple[str, DTypeLike]]` | List of (name, dtype) tuples | + +### Creating Table Data + +```python +from fastcs.attributes import AttrR +from fastcs.controllers import Controller +from fastcs.datatypes import Table + +class ChannelController(Controller): + channel_data: AttrR[np.ndarray] = AttrR(Table([ + ("channel", np.int32), + ("temperature", np.float64), + ("status", "S10"), + ])) + +# Create data using numpy structured array +data = np.array([ + (0, 25.5, "OK"), + (1, 26.2, "OK"), + (2, 30.1, "WARN"), +], dtype=[("channel", np.int32), ("temperature", np.float64), ("status", "S10")]) + +# Update the attribute +await controller.channel_data.update(data) +``` + +### Accessing Table Data + +```python +# Get the table +table = controller.results.get() + +# Access by column name +names = table["name"] +values = table["value"] + +# Access by row index +first_row = table[0] + +# Access specific cell +first_name = table[0]["name"] +``` + +### Common String Types in Tables + +```python +# Fixed-length byte strings (ASCII) +("name", "S32") # 32-byte string +("status", "S10") # 10-byte string + +# Unicode strings +("label", "U32") # 32-character unicode string +``` diff --git a/docs/how-to/test-logging.md b/docs/how-to/test-logging.md new file mode 100644 index 000000000..50ee38c6c --- /dev/null +++ b/docs/how-to/test-logging.md @@ -0,0 +1,54 @@ +# Testing Log Output + +FastCS uses [loguru](https://loguru.readthedocs.io) for logging. The test suite provides +a `loguru_caplog` fixture that bridges loguru into pytest's standard `caplog` mechanism, +making it straightforward to assert on log messages in tests. + +## The `loguru_caplog` Fixture + +The fixture is defined in `tests/conftest.py` and registers a loguru sink that forwards +all messages (down to `TRACE` level) into pytest's `caplog`: + +:::{literalinclude} ../../tests/conftest.py +:pyobject: loguru_caplog +::: + +Use it by adding `loguru_caplog` as a parameter to your test function. The yielded value +is pytest's standard `caplog` object, so the same assertions work: + +- `loguru_caplog.text` — all captured log output as a single string +- `loguru_caplog.records` — list of `logging.LogRecord` objects, each with a `.message` + attribute + +## Asserting on ERROR-level Messages + +Pass `loguru_caplog` to any test that exercises code that calls `logger.error(...)` or +similar. After the code runs, assert against `loguru_caplog.text`: + +:::{literalinclude} ../../tests/test_attribute_logging.py +:pyobject: test_attr_r_update_logs_validation_error +::: + +The same pattern applies when a callback raises: + +:::{literalinclude} ../../tests/test_attribute_logging.py +:pyobject: test_attr_r_update_logs_callback_failure +::: + +## Asserting on TRACE-level Messages + +`log_event` calls (from the `Tracer` mixin) emit at `TRACE` level and are only active +when tracing is enabled on that object. The `loguru_caplog` fixture captures at `TRACE` +level, so no extra setup is needed beyond enabling tracing on the object under test. + +Use `loguru_caplog.records` and check `.message` on each record for precise matching: + +:::{literalinclude} ../../tests/test_attribute_logging.py +:pyobject: test_attr_r_update_trace_logs_when_tracing_enabled +::: + +You can also verify that messages are absent when tracing is not enabled: + +:::{literalinclude} ../../tests/test_attribute_logging.py +:pyobject: test_attr_r_update_no_trace_logs_when_tracing_disabled +::: diff --git a/docs/how-to/update-attributes-from-device.md b/docs/how-to/update-attributes-from-device.md new file mode 100644 index 000000000..895eb5f2a --- /dev/null +++ b/docs/how-to/update-attributes-from-device.md @@ -0,0 +1,283 @@ +# Update Attribute Values from a Device + +There are different patterns for pushing values from a device into attributes to suit +different use cases. Choose the pattern that fits how the device API delivers data. + +## Update Tasks via `AttributeIO.update` + +Use this pattern when each attribute maps to an independent request to the device. The +`AttributeIO.update` method is called periodically as a background task, once per +attribute, at the rate set by `update_period` in the attribute's `AttributeIORef`. + +Define an `AttributeIORef` with an `update_period` and implement `AttributeIO.update` +to query the device and call `attr.update` with the result: + +```python +from dataclasses import KW_ONLY, dataclass + +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.controllers import Controller +from fastcs.datatypes import Float, String + + +@dataclass +class MyDeviceIORef(AttributeIORef): + register: str + _: KW_ONLY + update_period: float | None = 0.5 + + +class MyDeviceIO(AttributeIO[float, MyDeviceIORef]): + def __init__(self, connection): + super().__init__() + self._connection = connection + + async def update(self, attr: AttrR[float, MyDeviceIORef]): + response = await self._connection.send_query(f"{attr.io_ref.register}?\r\n") + await attr.update(float(response.strip())) + + async def send(self, attr: AttrW[float, MyDeviceIORef], value: float): + await self._connection.send_command(f"{attr.io_ref.register}={value}\r\n") + + +class MyController(Controller): + temperature = AttrR(Float(), io_ref=MyDeviceIORef("T")) + setpoint = AttrRW(Float(), io_ref=MyDeviceIORef("S", update_period=1.0)) + label = AttrR(String(), io_ref=MyDeviceIORef("L", update_period=None)) + + def __init__(self, connection): + super().__init__(ios=[MyDeviceIO(connection)]) +``` + +Setting `update_period` to: + +- A positive `float` — polls at that interval in seconds. +- `None` — no automatic updates; the attribute value is only set explicitly (e.g. from a + scan method or subscription callback). +- `ONCE` (imported from `fastcs`) — called once on startup and not again. + +## Initial Read with Event-Driven Updates from Puts + +Use this pattern when attributes need their initial value read on startup, but subsequent +updates arrive as side-effects of write operations rather than on a fixed poll cycle. +This is common for devices that echo back related parameter values in their response to a +set command. + +Set `update_period=ONCE` on the `AttributeIORef` so that `AttributeIO.update` is called +once when the application starts. Then, in `AttributeIO.send`, parse the device's +response to the put and call `attr.update` on any attributes whose values have changed: + +```python +from collections.abc import Awaitable, Callable +from dataclasses import KW_ONLY, dataclass + +from fastcs import ONCE +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.controllers import Controller +from fastcs.datatypes import Float + + +@dataclass +class MyDeviceIORef(AttributeIORef): + register: str + _: KW_ONLY + update_period: float | None = ONCE + + + +PutResponseCallback = Callable[[str], Awaitable[None]] + + +class MyDeviceIO(AttributeIO[float, MyDeviceIORef]): + def __init__(self, connection, on_put_response: PutResponseCallback | None = None): + super().__init__() + self._connection = connection + self._on_put_response = on_put_response + + async def update(self, attr: AttrR[float, MyDeviceIORef]): + response = await self._connection.send_query(f"{attr.io_ref.register}?\r\n") + await attr.update(float(response.strip())) + + async def send(self, attr: AttrW[float, MyDeviceIORef], value: float): + # Device responds with a snapshot of all current values after a set + response = await self._connection.send_query( + f"{attr.io_ref.register}={value}\r\n" + ) + if self._on_put_response is not None: + await self._on_put_response(response) + + +class MyController(Controller): + setpoint = AttrRW(Float(), io_ref=MyDeviceIORef("S")) + actual_temperature = AttrR(Float(), io_ref=MyDeviceIORef("T")) + power = AttrR(Float(), io_ref=MyDeviceIORef("P")) + status = AttrR(Float(), io_ref=MyDeviceIORef("X")) + + def __init__(self, connection): + super().__init__(ios=[MyDeviceIO(connection, self._handle_put_response)]) + + async def _handle_put_response(self, response: str) -> None: + actual, power, status = response.strip().split(",") + await self.actual_temperature.update(float(actual)) + await self.power.update(float(power)) + await self.status.update(float(status)) +``` + +Attributes that are updated as side-effects of puts can still carry `update_period=ONCE` +so they also get their initial value on startup. Set `update_period=None` instead if the +device response to the put is the only source of truth and no initial poll is needed. + +## Batched Updates via a Scan Method + +Use this pattern when the device returns values for multiple attributes in a single +response. A `@scan` method runs periodically on the controller and distributes the +results by calling `attr.update` directly on each attribute. + +Attributes that are updated this way do not need an `io_ref` with an `update_period` +because the scan method drives the updates rather than individual IO tasks. + +```python +import json + +from fastcs.attributes import AttrR +from fastcs.controllers import Controller +from fastcs.datatypes import Float +from fastcs.methods import scan + + +class ChannelController(Controller): + voltage = AttrR(Float()) # No io_ref — updated by parent scan method + + def __init__(self, index: int, connection): + super().__init__(f"Ch{index:02d}") + self._index = index + self._connection = connection + + +class MultiChannelController(Controller): + def __init__(self, channel_count: int, connection): + self._connection = connection + super().__init__() + + self._channels: list[ChannelController] = [] + for i in range(channel_count): + ch = ChannelController(i, connection) + self._channels.append(ch) + self.add_sub_controller(f"Ch{i:02d}", ch) + + @scan(0.1) + async def update_voltages(self): + # One request returns all channel voltages + voltages = json.loads( + (await self._connection.send_query("V?\r\n")).strip() + ) + for channel, voltage in zip(self._channels, voltages): + await channel.voltage.update(float(voltage)) +``` + +The scan period (here `0.1` seconds) sets how often the batched query runs. Scans that +raise an exception will pause and wait for `reconnect()` to be called before resuming. + +### Scan as a cache for `AttributeIO.update` + +When there are many attributes to update from a batched response, calling `attr.update` +for each one inside the scan method becomes verbose. Instead, the scan can populate a +cache on the `AttributeIO`, and each attribute's regular update task reads from that +cache rather than querying the device while the device is still only queried once per +cycle. + +```python +import json +from dataclasses import KW_ONLY, dataclass + +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.controllers import Controller +from fastcs.datatypes import Float +from fastcs.methods import scan + + +@dataclass +class ChannelIORef(AttributeIORef): + index: int + _: KW_ONLY + update_period: float | None = 0.1 + + +class ChannelIO(AttributeIO[float, ChannelIORef]): + def __init__(self): + super().__init__() + self._cache: dict[int, float] = {} + + def update_cache(self, values: dict[int, float]) -> None: + self._cache = values + + async def update(self, attr: AttrR[float, ChannelIORef]): + cached = self._cache.get(attr.io_ref.index) + if cached is not None: + await attr.update(cached) + + +class ChannelController(Controller): + def __init__(self, index: int, io: ChannelIO): + super().__init__(f"Ch{index:02d}", ios=[io]) + self.voltage = AttrR(Float(), io_ref=ChannelIORef(index)) + + +class MultiChannelController(Controller): + def __init__(self, channel_count: int, connection): + self._connection = connection + self._channel_io = ChannelIO() + super().__init__() + + for i in range(channel_count): + self.add_sub_controller(f"Ch{i:02d}", ChannelController(i, self._channel_io)) + + @scan(0.1) + async def fetch_voltages(self): + voltages = json.loads( + (await self._connection.send_query("V?\r\n")).strip() + ) + self._channel_io.update_cache(dict(enumerate(map(float, voltages)))) +``` + +## Subscription Callbacks + +Use this pattern when the device library (or protocol) delivers value changes by calling +a user-supplied callback rather than responding to polls. Wrap `attr.update` in an async +callback and register it with the library. + +```python +import asyncio + +from fastcs.attributes import AttrR +from fastcs.controllers import Controller +from fastcs.datatypes import Float + + +class SubscriptionController(Controller): + temperature = AttrR(Float()) + + def __init__(self, subscription_client): + super().__init__() + self._client = subscription_client + + async def connect(self): + # Register an async callback that forwards updates into the attribute. + async def on_temperature_change(value: float) -> None: + await self.temperature.update(value) + + await self._client.subscribe("temperature", on_temperature_change) + await super().connect() +``` + +If the library only supports synchronous callbacks, schedule the coroutine onto the +running event loop: + +```python +def on_temperature_change_sync(value: float) -> None: + asyncio.get_event_loop().call_soon_threadsafe( + asyncio.ensure_future, self.temperature.update(value) + ) + +self._client.subscribe("temperature", on_temperature_change_sync) +``` diff --git a/docs/how-to/wait-methods.md b/docs/how-to/wait-methods.md new file mode 100644 index 000000000..d61fb8bec --- /dev/null +++ b/docs/how-to/wait-methods.md @@ -0,0 +1,108 @@ +# Synchronize Operations with Wait Methods + +This guide shows how to use `wait_for_value()` and `wait_for_predicate()` to synchronize +operations in your FastCS driver. + +## Wait for a Specific Value + +Use `wait_for_value()` to pause execution until an attribute reaches an exact value: + +```python +from fastcs.attributes import AttrR +from fastcs.controllers import Controller +from fastcs.datatypes import Int +from fastcs.methods import command + +class MotorController(Controller): + position: AttrR[int] = AttrR(Int()) + target: AttrR[int] = AttrR(Int()) + + @command() + async def move_and_wait(self): + """Move to target and wait until we arrive.""" + target = self.target.get() + + # Start the move (implementation depends on your device) + await self._start_move(target) + + # Wait until position equals target (timeout after 30 seconds) + await self.position.wait_for_value(target, timeout=30.0) +``` + +## Wait for a Condition with Predicates + +Use `wait_for_predicate()` for more complex conditions. The predicate is a callable that +takes the attribute value and returns `True` when the condition is satisfied: + +```python +from fastcs.attributes import AttrR +from fastcs.controllers import Controller +from fastcs.datatypes import Float +from fastcs.methods import command + +class TemperatureController(Controller): + temperature: AttrR[float] = AttrR(Float()) + + @command() + async def wait_for_stable(self): + """Wait until temperature is within operating range.""" + + def in_range(temp: float) -> bool: + return 20.0 <= temp <= 25.0 + + await self.temperature.wait_for_predicate(in_range, timeout=60.0) +``` + +## Handling Timeouts + +Both methods raise `TimeoutError` if the condition isn't met within the timeout period. +The error message includes the current value for debugging: + +```python +from fastcs.logging import logger + +try: + await self.position.wait_for_value(100, timeout=5.0) +except TimeoutError: + logger.exception("Move timed out") +``` + +## Early Return for Already Satisfied Conditions + +If the condition is already satisfied when called, both methods return immediately +without creating an internal event: + +```python +# If position is already 100, this returns immediately +await self.position.wait_for_value(100, timeout=30.0) + +# If temperature is already in range, this returns immediately +await self.temperature.wait_for_predicate(in_range, timeout=60.0) +``` + +## Concurrent Waits + +Use `asyncio.gather()` to wait for multiple conditions simultaneously: + +```python +import asyncio + +from fastcs.attributes import AttrR +from fastcs.controllers import Controller +from fastcs.datatypes import Float +from fastcs.methods import command + +class MultiAxisController(Controller): + x_position = AttrR(Float()) + y_position = AttrR(Float()) + z_position = AttrR(Float()) + + @command() + async def move_all_and_wait(self): + """Wait for all axes to reach their targets.""" + await asyncio.gather( + self.x_position.wait_for_value(10.0, timeout=30.0), + self.y_position.wait_for_value(20.0, timeout=30.0), + self.z_position.wait_for_value(5.0, timeout=30.0), + ) +``` diff --git a/docs/images/data-flow.excalidraw.svg b/docs/images/data-flow.excalidraw.svg new file mode 100644 index 000000000..7c844fe00 --- /dev/null +++ b/docs/images/data-flow.excalidraw.svg @@ -0,0 +1,4 @@ + + +eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1dWXfaSlx1MDAxMn7Pr8jxvF40vS/z5oXYON6JbZyZOT4yXGJb7Fx1MDAwMeFtTv77VMtcdNpaXHUwMDAyXGbOJTGc3CXaaLrrq/pq6dL/Pnz8uFx1MDAxMTxccryNf33c8Fx1MDAxZetux29cZt2Hjb/M8XtvOPL7PThFwr+P+uNhPbzyLlxiXHUwMDA2o3/985/uYOBEdzn1fvflTq/jdb1eMIJr/1xyf//48X/hv+GM3zD3VzrqrvXYVPT6VnpcdTAwMGacXFxI+cVcdTAwMGZvXHIv+jkgdzjsP0SHXHUwMDFm4Vx1MDAxOJdcbjtcXDEuJeVMcz45+1x1MDAwNGcxUsRhjEmCkURS6cnZXHUwMDA3v1x1MDAxMdzBXHUwMDE1aHLkzvNv74LwJuFcdTAwMTDBMZaYXG5OtVx1MDAxMpNr3N5tx0vcNVxuhv22t93v9IdmhP/AWuI6icZ449bbt8P+uNeYXFxcdTAwMTNcZt3eaOBcdTAwMGVhQqLrmn6nU1xynsKnw8TCXHUwMDA0bqS+4/LHiEnqeN5d8KW3dz1vZCZcdTAwMWRPjvZcdTAwMDdu3Vx1MDAwZl7mJvpcdTAwMTVmhINKI1xcn/9GY1x1MDAxYbpdr2JcdTAwMTaoN+50Jof9XsMzU79xI3qJr+s1fnzdz9WNlo78OPI9XHUwMDFhvOeZJ2OuXHUwMDEwkZRhNTlcdTAwMTOJXHUwMDE5pipz+KjfXHUwMDBiZY4rTqlEKlpwf7RcdTAwMDNyXHUwMDE2hI9tup2RXHUwMDE3LYJcdTAwMTlbOSaD0W9cdTAwMWNcdTAwMGZcdTAwMWHuyy1cdTAwMTgkSHCOtEIsmpmO32unJ6DTr7ct3zLo+3FcdTAwMTE3n+j/PkYyXHUwMDEz/mXy///9a/rVWYmMbv+QesxGx1x1MDAxZFx1MDAwNdv9btdcdTAwMGbgd52YMaXHP1xu3GGwXHUwMDA1y+j3btPnvF4j50x416ZB4J3nZoRcdTAwMDLui59LQ9Xr3PRcdTAwMWYsc9bt33uH/sswR5d+cPdjlX5e+CEmNCmdsbfz7XJcdTAwMDc3q/290e5o/OTuN4/b7Zl0hsBYOyD+jGBcdTAwMDKyXHUwMDE3XHUwMDEzvVx1MDAxMFx1MDAxN1iDRlx1MDAxMZJSxCTSXGaz2ZSGdlx1MDAxNMFcdTAwMDIhIZRQXHUwMDFjRXK5Vlx1MDAxYUmlMVhcXGkwQoRcdTAwMDQwRIOJKVxygmSe0qCCIUmJjuzAn6s0SlmRXFxrjY1gS5yc9Vx1MDAwZsp+6fCLOz45U9fnx9dZrTH06sFcdTAwMGJuk2xcdTAwMDNm0mGUKUm4wppGzMCAg1PuSKk1XGJcdTAwMDTXjIro7E/FXHUwMDAxkuJwJlxiXFzEMFY8XHUwMDEy1IlcItFcdTAwMTHspiuOJlJ1hN6L4lxiZlVcdTAwMWM0V3FcdTAwMDBcdTAwMWMoKFtcdTAwMWTp9EhxcFFANqRcdTAwMTRgfV9FNibDi1x1MDAwNjpcdTAwMTFH9+x2t76HXHUwMDFho0rteHuvdrp7vePubsRhPFx1MDAxMcnAe1xmNiYnvmdcdTAwMTC8uFIqgE3xOJNjTFwiRmnmMCB5mHBOONFJxFx1MDAwMJyKXHUwMDExo5XDsyAh81jX91x1MDAwNZKxXHUwMDFkJEml/Fx1MDAwM1xyWmFEXHUwMDE15jY0sJiwp9BAXHUwMDEwXHUwMDE4XHUwMDE1qrnEr4HDm5jRUPDg939cdFx1MDAxN7I/jC9jv1x1MDAxN1T955eBJ45+crt+J1x1MDAxNMPEczY7/q2ZgY06XGbZXHUwMDFibsSnIfDBuZ1cXND1XHUwMDFijbiJqMNDXb/nXHIrs9ia/tC/9Xtu50v+2N1x0D/zRi+jXHUwMDBmhmMvPjfe3oR8OoRcdTAwMTeA97R2cTBcdTAwMTQj/7ZcIj4/b5Hq4VPwMJ7D5knhIHBBNNNKMSyTLjZlXHUwMDFh/G+pueZcZlxiMyFZusypcjC45kyBXHUwMDBiXHUwMDBlrnqkZtdWbzqg7+dcdTAwMDC0JFx1MDAwMjNNSFxcZH/gWapcXDxjXCKxIOBcbi3buu13Kv6R37h7wGf1XHUwMDEy5Sffbr3BYFx1MDAwNa1b8TiLrFx1MDAxYsy1ozRcdTAwMDbzpsC6MZmEhlwi06DBwDhS+DBcbvRcXPFcdTAwMTgvWdu66dB4mFx1MDAwM1x1MDAxYYgjxiWiNlsnXG6woZCSXHUwMDE0cbk6LuNPW3c+ilx1MDAxYqe/z8xNMS9pM5dcdTAwMWP2cizcw9HOOOiRXHUwMDFh2Tw+2CqV77+djlx1MDAwZt05LFx1MDAxY9eORlxuUVx1MDAwNFx1MDAxY1/TXHUwMDA0iDHW4EZcdTAwMDM4kYJcdTAwMGKo1lx1MDAxMcYn9k1Ih2vDYsEzpJws6tW9r3DQ81x1MDAxYyBmXHUwMDE4yFxiplbCKlnm6Fx1MDAwNMRCgXNBNH1cdTAwMTVhLbRwu4/jy/rJjnSDs06j5FXklbe3ilx1MDAxNq5wnIVcdTAwMTZcdTAwMGUhh0omXGZ3XHUwMDAzU5WKlVx1MDAxMkqmgENcdEeYXHUwMDBmg2s55VhkwTGXhXtX4JBoXHUwMDFlcFCkJWfCglxyITOR0lx0NjDHXHUwMDA0zuvVc+b23GHjXHUwMDAxlnFcdTAwMTWM3Fx1MDAxNFx1MDAwYpM2ctmhL8fQ7XyqlfdcdTAwMWbvdL2hn/uH366OxLdcdTAwMWKZxbHX6fiDUcbMXHUwMDEx5FxiXHUwMDBlSKZcdTAwMTK8Nk2SjpxEXHUwMDA2yoyAplRcXIlcYlx1MDAwNT+RXGa3OoarSoZcYlx1MDAxMFZp46pIOkxJJpEgmEmA/Fx1MDAxY8gmTe0x9rcgu+GO7rxfXGZtPFx1MDAwN7Rh0WDWpVx1MDAxNdsyXHUwMDE2hkljW4LXh5BYumP3XHUwMDEwfK3dPDz1v3qfR335dVx1MDAwZvdu7k6XafZwXHUwMDFjXHUwMDFkrzd7RHS++qdlt/Rp/2TAdqv7p1+ah7PWXHUwMDE1UEdoRFx1MDAxOFxmXG70ajJuqYRJIEolwauA/8YyiJNcdTAwMTShXHUwMDAzwi80XHUwMDAzOok1xpjSLFrgXG74XG5cZo9cdTAwMTGUIVxy6vv3QMvfYFx1MDAwN7FaOGkopVx1MDAwNs2ms5ZwIyw0oLkgQkQpgjR7XHUwMDE1in6DpGGupJpPVkaj52WwPHdcdTAwMTIxtnw/SoRerG15OKhstyulk+ftnWs+8J6uusiN6ZeNbr9cdTAwMTGKot9cdTAwMWL5XHIvfqbpP0bfnfydJlx1MDAwMkBDsiOkXHUwMDE2RCR+p4NcdTAwMDSVXHUwMDA0K66pwkCjVOZ3Rlx1MDAxMvX7pDtZvXvub/X9c8E292vbrd1g7/BqxlwiXHSCXHUwMDFkLjVHnGrwXHUwMDAyXCJrbzCtNXJcdTAwMDBOXHUwMDA0XHUwMDFib1xmXHUwMDBilk3cIFx1MDAwN4E8KS4lx+BcdTAwMTljSS15XHUwMDFjI11SmOBcdTAwMTlWlFx1MDAxMFx1MDAxOYuQrTVgSlx1MDAwM+4urFx1MDAwMbGG9Vx1MDAwNPYnbPFhWO7cslx0XGbUj0tcdTAwMDJcdTAwMDD5M1VgqUBYw/NZOV2mXHUwMDE2zC+lyNOP94/3pd2j5qN7c9N6wjW5WW1cdTAwMWZ4S9CPnCqCOdMmwVx1MDAxN2XDzVx1MDAwN/QjXHUwMDEzzNTOXHUwMDEw+Fx1MDAwM+58gXpcXDUlqPavtset2zLrXHUwMDFk35ev79tHu0FwM6tcdTAwMTKkXHUwMDBlgd+NXHUwMDExpVx1MDAxOLEkXHUwMDBilFxmOzBPTCDCgSFEyI90IHhBgilFlVx1MDAwNHeLSFx1MDAxYT0gVjnGXHUwMDFjXHUwMDEwPkpcdTAwMDFjlJhM+VpcdTAwMDfm6cCLZZSOIcyB5thCiCD1ub6UxCD0wJNWJ1x1MDAwZrBsXHUwMDE1mCur4fmsmP5cblwi+EaKXHUwMDBlU0YxU1xmtFx1MDAxZFx1MDAwMqX2V3JcIoyjrShXXG70IMNIZ+vmfkcqWOyzXHUwMDE3hoAx6DlcZoRcdTAwMTmIsWCapeJGmjlYI6SQkVx1MDAxYc6ygSNpJEdcYiBcdTAwMTHMlNOiRUPA70vpXHUwMDExu86zXHUwMDE39HBBqFx1MDAwMpm1KDeZLVx1MDAwYvip25RcdTAwMGXDRG9cdTAwMTNcdTAwMDKeJ5RcdTAwMTNJ44846mZcdTAwMTBcZs8uVyFcdTAwMDA8JfKaXHUwMDBlXHUwMDAwp1x1MDAwN76c8G+xOpyW51x1MDAwNOtcdTAwMDY0llx1MDAwMo/XlIFcdTAwMDKM5OGFzWhHXHUwMDAz0SFcdTAwMDQoIObZrTJcdTAwMDTujsd/uVx1MDAwNcc49tA1kJNAplx1MDAwYpevXHUwMDEyQbRU1MpdeD68wWprKYhES49cdTAwMDNcdTAwMTeHXHUwMDE3XHUwMDEyMvli4CZnvv9V9Nxixv765za22pvN21Pi31x1MDAwZjtPtfrluHSx3V3BuHXxOFx1MDAwYmy1QFx1MDAxYTw1rSmRXHUwMDE4K52sR5KaOqxcYuFcXIV1XGZaMXBtgFx1MDAxYS1cXI70vlx1MDAwMM5mt9QwuUhgzK07WJjOL0diUlCuSSxcXL1SpnpcdTAwMTUs9Vx1MDAxNCNptdTLNtTFXHUwMDAx7Fx1MDAxOUpupYNcdTAwMDTXnFAg3yq5zSRmqDmiIENcdTAwMTZLjZOWem2o58ExX9hQS1Cw4FxmWe10tngpSteaMmtMxavAXWT3ivOgXHSRnMuejsbuVsDlqVx1MDAxN+xcctTl4KTl33/KKX+a67llv9mqXHUwMDBmXHUwMDAyvNuq+sPH6vjxM71dxfxy8e+fkl/mjqJCUi6Z0ihpqUHHO8ZRNlx1MDAxOVx1MDAxNlx1MDAxMFxui6m2bUFcdTAwMDXUY6Xg94GbLVxiU2uA51x1MDAwMXxcdHFEoSXMNJXWfevx7b9pkHPBiJSS/6G5lOTG9bRIRndngPpcdTAwMGLSJm+UVsZMKFx1MDAwNWtOiFx1MDAwMjgn0sphZolwYuqqXHUwMDE4J4xjnU2gr27mpFhcdTAwMGZcdTAwMTfFXGYlJ1x1MDAwZXghgiEsuGKZmOHEXHUwMDEzyaEwXFyvXZFcdTAwMDU0nJgjaEg5NTVO1qAhJ1x1MDAwNUXVoHiEpvpNOnAs7IqsRNBwilx1MDAxYmBzRZZcdTAwMWUzLKFWc2u0u99Ccr99u3vjV3WbzMpRpFx1MDAwMzZcdTAwMGWUm8KIsWTtN2PSMZv5QHKAqlx1MDAxMFx1MDAxZNtDXHUwMDEzq1x1MDAwMZGEaUHNXHUwMDA2KImZbWeE1I5cdTAwMTRcdTAwMTLUI1x1MDAwMkWJ1FxcrOVd7XaSenHSQlx1MDAxNEIm92mrI8XxnS8prMNcdTAwMWFrrP7U8o98MTWfjID+XHUwMDFhXHUwMDBls9K5xM98X5VbXHUwMDA3JXT69WFcdTAwMWNcZnnv6Vx1MDAxM5qtooJzYVpgaFxy861xfM9pWFdLicklMpOMovGas1x1MDAwMreHYelQuIUxIFx1MDAwYkzNVULmXHUwMDAx+aB41Vx1MDAxNEg03uUqkE9L8HpcdTAwMDRcdTAwMDdf1ZJKNFx1MDAwYiFyXHUwMDBiyGBdXHUwMDE08MDXXHUwMDE1T0yGl1xiXHUwMDE0WLlnTERcdTAwMDdn+1x1MDAwZv7d551n3azJbrM6blx1MDAxY11cdTAwMWPOXHUwMDEzKNBEXHUwMDEyXHUwMDFj2660XHUwMDEyXHUwMDBlVUbYV15cdTAwMTlcdTAwMDVDP1x1MDAxNeFM6aNcdTAwMDI1Y1/DWdxcdTAwMGbws1x1MDAxY1xuOp2bskaMklx1MDAwMVSMsHIoaHRCsVx1MDAxMlx1MDAxOPyyjJ5cdNtcdTAwMDaaLZ3aTDLBlrotNo/38b5cdTAwMTRNTqmqvWVcdTAwMDGlyCySzfsg+TtbwEhcdTAwMGJAXHUwMDAxfVx1MDAxM+cjMiGvcD6qgFx1MDAwMKvvXHUwMDEx61x1MDAxNvUrfI8pNjrteySHvVx1MDAxY9ej++S2rj1V6Vf3+d7NsHX/rexaXHUwMDFhj9hcbi9cdTAwMDG8jtHAWlxuXHUwMDE2X5CXXadMOUb7wSllgmmW7TdcdTAwMTa8XHUwMDEy6YBGJ6ZnXHUwMDAz55TLefamvS/87i1MXHUwMDE0zDZ6Q6VtjkZBny3QzDB+uHWJjbamXHUwMDExhZvDm1ZTadrd2nm+apVr+utndfXbXHUwMDEzhVJW3P9oqmBfxVx1MDAxOaiCIFx1MDAwMrRcdTAwMTij4P5cdTAwMTFTXHUwMDA3l1I1YJtcdTAwMWNcdTAwMTRufmJcdTAwMTLzbIk3hmmmZn+7ksj0Z1WW5kZrppCraSpzlEwgU7TGsNX3yNZRxJKqXHUwMDAyIED56jGF8/CRq8BcdTAwMTWmXHUwMDE46kxcdTAwMGKX1MCXw1x1MDAxNs5cdTAwMGaean6Dd+vtNjo/6F1dXdx/sextt7NcdTAwMDVuukxQkzxcdTAwMDeUJlx1MDAwM5XAXHUwMDBmXHUwMDFjjVx1MDAwNMOmSVx1MDAwNcJ8JrZANahPRoRSXHUwMDE0XHUwMDAzqDlbh1x1MDAxNfIwvL8wWyCm53KY6rHRhfxqKCVghZB6XHUwMDFktF/HXHUwMDE2+tfq6Kh34YsxOdkptVsn1eblxe/PXHUwMDE2suL+R7NcdTAwMDX7Ks7GXHUwMDE2mKOwKcxQStJUgSXn0lx1MDAwMaqFhJDENJq20Vx1MDAwNexcYr1cdTAwMGUsvFLVfJ6dLlx1MDAxOG1cdTAwMDJ0XHUwMDAxWSOV+TtdXHUwMDExJVx1MDAxYUlO32Sb10Js4WR80/FHd6tAXHUwMDE3pljqNF3IjHw5fFx1MDAwMZdLT+Vj7Fx1MDAxZVTdvYutg4Py/TBozZGEMPt8NdEyLiNh20bTzk1QYFx1MDAxMsqQfiGyTa2s4Vx1MDAwNWBcdTAwMTlcdTAwMWFcdTAwMWNcdKK10pjFZGyN4iSKXHUwMDBmXHUwMDE2z0NgjTjiwlqzIFh+L1x1MDAwZnDQqDKb2X9cdTAwMWRjaKPaUFx1MDAxZJZuyiebpFs5fEKH3p3+7Vx1MDAxOUNW3P9owmBfxFx1MDAxOVxiXHUwMDAz5zBRWFx1MDAxOVViRE+mXHSDduBcdTAwMWbTbFxizkuadU2AbkjwbCRcIsQ0XHUwMDFikuvwwjya5nB2vkA0zL+g1jdcdTAwMDPQXFxcdTAwMGaEc1g28UY7J6NvfVx1MDAxNV2w90D/xVRhipHOUoWldz+vyC/jJ9roXHUwMDBlvlX2g/r95v1D7aA6x1ZcZlx1MDAwM1HKXHUwMDA0XHUwMDEzXHUwMDE4XHUwMDAwKJKZRODxjqaMXHUwMDEwTVx1MDAwMedUWXqfXHUwMDBik2o0b1x1MDAxM6KIcmnDr2mvXHUwMDBl56UgXHUwMDA0XFwvReeJNLyvbpjVWYlD/lx1MDAwYkBcdTAwMThcdTAwMDFkmeSEXHUwMDA15pxcdTAwMTdUbWNcdTAwMGVrxJdcdTAwMTlqeFx1MDAxMc/NreOzTmuzQZ9OR9vXtdaVbFeW2iN9SVtcdTAwMWSKx1nYPoBcdTAwMDLZ1piaPimK0yTZNjXAjnnPXHUwMDE2lkxQqUi2k1x1MDAxNFx1MDAxMVx1MDAwZTG7mShXUphcdTAwMTZcdTAwMTWLllx1MDAwMr8v0HyZ3Vx1MDAwNlJcdDxDXHUwMDEzbtvTwLKWcfI+XHUwMDEwjFx1MDAxOFx1MDAwMv23gptcdTAwMTIrx6tQXHUwMDA2PMVcdTAwMDSljWB80MuxgZeDXHUwMDFhXHUwMDFhdp9P9fbTqLLb0aSs44XGxVx1MDAxZJC4Y6JZlJnOsVwiWo1wvoDcUsRgmaSWVGfL+NdcdTAwMTXAy4Ty+TJcIu1cdTAwMTJcdTAwMTaLWDuoY0Jy02hcdTAwMTTojVx1MDAwMP/jT33dZmFcdHBpXVx1MDAwM/wxo1Pqg9Fu+fPtdfmucX2xR8jd871cdTAwMTYzXHUwMDExXHUwMDAymGVcdTAwMDdcdTAwMGKYcNM1SKLk1iAhkFx1MDAwM7ZcdTAwMDZmXHUwMDE5a9NZMqtTMOVcdTAwMGVcdTAwMTfF/YRiXCK1XG5bg1jq+N/aePpcImf3o73ztJacgN9i48sqV1uY1SOYrtLbeX8ygut+73owXHUwMDBlrsF8d8xC/6dcdTAwMTfuunFcdTAwMDY5/jKez19cdTAwMWWGXHUwMDAymM9cdTAwMTSC/iCPJiR+RZpcdTAwMTPMNOzlkIUj97l76DW3n9uDO6/j6vFgWLdU7VmBLVx1MDAxZESkUZCIcJzkXG7aXHUwMDE0JFFg8khQqpUlNzZcdTAwMDOuyVx1MDAxYde5uK7NXHUwMDAza1gkyZHVXHLOb4VtXlx1MDAwZqDUKnXCzkP1MqDc8Zq/XHUwMDAyycuG70OJPtzqq9HFqFxcq1x1MDAxZH456nzbrlx1MDAxZc1cdTAwMDRfiZBcdTAwMTPm/qnpTZPy07GJY1NJgT2ajZ+Wd5mB4camzVx1MDAxZlNKMPDmIzl/XHUwMDFkfN/eTV8p+F7NXHUwMDBlX3C1TLGh9V1mXHUwMDA1bcBcdTAwMDQhVFx1MDAxMqTfxE1fXGK+lWNnlFc2v1x1MDAxYajNjHA5YEVl7/G0/alSXHUwMDFknm6dsnp3c+v03PJcdTAwMTJ7SyFcbmbKXHUwMDExYGNcdTAwMDVcdTAwMDdrKkRcdTAwMWGtlDiUXHUwMDEw825cbqpcYlDtXGZapZq6w2WN1ny0fp2jXHUwMDEyXHUwMDA1oEq0sFx1MDAxNreR/EpcdTAwMTTAquk9q1bP2Fx1MDAwMlx1MDAxNsb5pasrg9f0XHUwMDE4l4PYqzN//1x1MDAwNPVLT/T8cYtcdTAwMWZtb1dZuzFcdTAwMWJizWtwlaZMU9NqPun2amyKVE37J81cdTAwMTBcIjRbc1x1MDAxMr6JXHUwMDAzT6k0X9PjXFzEurMjlmBcdTAwMDHAI9hcdTAwMTZcdTAwMDdX+VxyMVx1MDAxOJhlXHSOy+q9Se3lkSnn8Wz1UTzjuJeD7Fx1MDAwMOPjwZa89IPnqiBfWzV1WZ+JOKeRndxCXCLA0lx1MDAxMlNoxuFcdTAwMGZcdTAwMDJcdTAwMWGdRbbEjq1H39rZzUdzY1x1MDAxZTRTzqR5449cdTAwMDXOsULPTFNskyt+o30jXHUwMDBiO7spZIxWXHUwMDE4xMXDnVx1MDAxYrtcdTAwMWZ+XHUwMDA02zfcwaBcdTAwMWFcdTAwMTg98DNcdTAwMDOyce97XHUwMDBmW1m5/0cz/JjUeYh8I/NemDj5/uH7/1x1MDAwMXuzV+wifQ==TransportUserHardwareAttrRWAttrRAttrWSendUpdatePublishPutIO_on_put_callbackAttrW.put_on_put_callbackIO.sendIO.updateupdate_callbackAttrR.update_on_update_callbacks diff --git a/docs/images/overview.excalidraw.svg b/docs/images/overview.excalidraw.svg new file mode 100644 index 000000000..5077e1a84 --- /dev/null +++ b/docs/images/overview.excalidraw.svg @@ -0,0 +1,5 @@ + + +eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1dWVfbyFx1MDAxMn7Pr+BwX+aeM/St3rvzRlhcdTAwMTJC2LeQmTlcdTAwMWMvMlxijGRsmSX35L/falx1MDAxOSxZi7FBXHTmXHUwMDA2T1x1MDAwZVx1MDAwM2pJbnXXV/VVdVXrv+/m5uaju443/35u3rtt1Np+s1u7mf/TXHUwMDFkv/a6PT9cZrCJxX/3wn63XHUwMDExn3lcdTAwMTZFnd77//yn1umQ5CrSXGIvXHUwMDA3V3pt79JcdTAwMGKiXHUwMDFlnvtcdTAwMTf+PTf33/gntvhNd73x9X7L+2D5/k7TO/5Cd6LV1kF8aXzSQ4fafuAlR29dR5iRxDIqwVrOjZJm2HyHzVx1MDAwYkZzXHUwMDAyUlEqpdSGXHUwMDBiPWy+8ZvRXHUwMDE5nlx1MDAwMsMjZ55/elx1MDAxNuEhIzVcdTAwMDGgVmpccoJaNTyjXHUwMDE2nLa9kWt6UTe88JbCdth1XHUwMDFk/Fx1MDAxN/Xcf0lcdTAwMWbrtcbFaTfsXHUwMDA3zeScmmyaVis5p+W323vRXXxnXHUwMDFjU1x1MDAxY7v5zP2P7vtKM8eHVzVrvTMvdVx1MDAxOX7j6Vng9XojXHUwMDE3hZ1aw4/cuMjkXHRcXO86a814av5J+tStXXprbm6Cfrs9POxcdTAwMDdNz1xy+3zt08iXXHUwMDA1zfsvXHUwMDFiObvnee5cdTAwMGVUU2OZNMl3JoJEgbPs4c0wiKWK4eBcdTAwMWKwxiRz6veWUZSi+LatWrvnJVx1MDAwM+06sZJcdTAwMTKz5Fn6nWYtuu+J5oJxrTWD5KYoVVx1MDAxN9mut8PGRcG3dEI/LcXuk/w2l8hF/Mfw93/+fPzsrMwlXHUwMDE3v8vcZL5d60VL4eWlXHUwMDFm4VNtu1x1MDAxZeVcdTAwMDY+qnWjXHUwMDBmOFl+cJpt84JmSUt81WK3XHUwMDFi3px5tdzU43WlbZ2wfXdcdTAwMWHPZzxcXPHhXHUwMDFmf1x1MDAxNlx1MDAwMZ1Dw/vy6eoo2N/RXHUwMDFmm5+vb8/s8n5cdTAwMWXokXdcdTAwMWJlgG60IYZaoY1kmiueoHJcdTAwMDB0XHUwMDA2XHUwMDA051ZbjSpBXHUwMDE4kzQ/XHUwMDAwnSpFlNSSXHUwMDFirVx1MDAwNM+jXlx0Yoy1eFxu11xuT0lcdTAwMDRkXHUwMDAy2FtNXHUwMDFibCzso24t6HVqXZTPSqGfueox5FN4NvTPXHUwMDBmp8A+VdbifGhRXHUwMDAwfktzR1x1MDAxZrCP+kJzXHUwMDAwZSuGPlxuhrLyKdCPRVx1MDAxMlx1MDAxZr+F6Gv0UlNcdTAwMThcdTAwMDbRnv/ddVpSYimKXHUwMDAyk1x1MDAxNqhAmyNGzlqtXfrtWPuO3HSx7Z9cdTAwMDaxdfNaKdHAXHUwMDAxiXy0pMPmKOwkrVxyvF9ccq1hNz9RYdc/9YNae7+kw7V+XHUwMDE07nq9QZejbt9Lj4b36Vx1MDAwMVx1MDAwZZQwOVx1MDAwNshH9eXFj41cdTAwMWV0edO35nqpfrqyaydcdTAwMDEyqnRNmOCggHIm0lBcdTAwMWNcdTAwMDBcdTAwMTkkQamh+DFcXIqU4DxcdTAwMDCZWUlcdTAwMDQoRLK0kiZcdTAwMTKSIJlcdTAwMTGpXHUwMDE5xX/4XHUwMDA1IGTyXHKPI5m1rCfEb4Lki2mAzK3kgmuTltB7IGtts0dcdTAwMWaAjPiXSoI2yTxWZMSFRUPwfCQvoNR43WI8XHUwMDAzckf8NjTJ0jKuzMhJL1xu52yvq1x1MDAwMfVm/UPrcFx1MDAwNc4vXHUwMDFhsNP77onVztnJ9UTWXHUwMDE5tVx1MDAxZGFMgbHUXHUwMDFhYDqZmFx1MDAxONQoXHUwMDAxRHCmOGOa4oAmnG9onaklnFpcdTAwMGVcdTAwMDbVvuaSXHUwMDE2WGjBXGIwo1x1MDAxY/YpXHUwMDE3aKvfLHQxrm+nwLXEcWSUyVwiXFxzW45rSplVoHWFuFZcdTAwMWF1XHUwMDA1emlMPstCrzp+vFeIaM6JYszi43JcctqmjMdcdTAwMGJcIjrb32qwvLtcdTAwMWOt6W77cHO1eVx1MDAwNSeLR6ec92FcIixLKoiiQktUsai4XHUwMDEzXHUwMDE5jaFMXHUwMDA1XHUwMDEwxangXHUwMDAzXHUwMDFhbvNcdTAwMWU1Ujfi/DpQaOaVSbD+XHUwMDA25KmBPFx1MDAwNdW2OKScXHUwMDBiS9NcdTAwMTJ6XHUwMDBmZGlLmTY1OFx1MDAxM8hYbdLZKYB835BIX0pcdTAwMDL3w4tcdTAwMGYrW0Gj+/n8e91cdTAwMDTs9nAl+Dp8nlx1MDAxMSmsOc9yftjy489x993pe02xvNVcdTAwMGbs7cHpx9ZRa3N7Y3Gy+97/9lPVz1pcdTAwMTB53Voj8q+9vTOv3Z5MXHUwMDExyZGzXkhcdTAwMTOVd71cdTAwMWGddEqbXHUwMDFih1SsXl6c3lx1MDAxY5ngZv2mfsjyOqnrNaKBXCJcdTAwMThVTNxYQoU1XHUwMDE0tYtcdTAwMDCTXHT1SatcdGfWsX5uKZf5XGKAXHUwMDA0Q4BzsMYyJXmB54DKXGKfwFBkKohcYnQxZkoxiczxXHUwMDE3VEx30zBcZmSHOOy8iGFgg85cdTAwMWVcdTAwMWVqJoZTJaWl1Wum+qZdX/9cdTAwMWMt6ejgmKv+2idcdTAwMTGeVqCZTs52XHUwMDE3pVq9Xjlf31o8OPiy9fnwZLOC++6fsYta+1x1MDAxMz/4urja+Ua/Nq86q+bFNN5cdTAwMTiAXHUwMDFmbWq7XFz/snhcdTAwMWS2Plx1MDAwNedr30AuXHUwMDFmyclcdTAwMWNcYk6JZSDRRVx1MDAxMJpnSYdUgkhcdTAwMTdWXHUwMDExXHUwMDFhT1x1MDAwMmFz2Fx1MDAxNkxcdTAwMTKqXFyo2Vx1MDAwNVCV1iqPbmaBSKBWoStcdTAwMDJcXFx0OlvwLo/uhy7S+yvxXYdp4vugjEBcblx1MDAwMapcdTAwMDDghuaOPuBbUIFcdTAwMTPF9Fx1MDAxYvGohnh41zg4J+0w7LyfW3G/f8Ff/1x1MDAwZf5cdTAwMGVcdTAwMWNcdTAwMDPohu22130/tzT83bVcZiQ27Ea993Ntv1x1MDAxN/21/3DgXHUwMDFm11xc7/vt5kmt4//xb/dnt1x1MDAxZvzx70I6w1x1MDAxNDFcdTAwMTbZJ1x1MDAwMDqFiqNvUExn+Einfzad+dVcdTAwMDMyOUlcdTAwMWGjQtX3aH9r9dare1e3d5fL1+ffl+jGxFx1MDAxY4lqt1x1MDAwZaK54IZRMGlvYFx1MDAxMIhBRcqMXHUwMDBi5HGuJKRcdTAwMTY5hopUXHUwMDAxoVqiX8bBMFx1MDAxN2QtUKRGXHUwMDExib5cdTAwMDZlWlx0ZVXyJbNcdTAwMTBgnVx1MDAxZJpUh52plknRJTC2cJlUpVRDRo1Ka1xmeuLwpFXSl2Azza2N/Vx1MDAxM2/162dcdTAwMDM7x9eHzbrpQ1SBXHUwMDFh1cBcdTAwMDCNNFXA0sB7OpvxtkRcdTAwMDTBttfdO77Yv1x0RdBY/n420Vx1MDAxYYfmllx1MDAxOM24YMJwbSCDQk3RVTFcdTAwMDadP66YXHUwMDE2Ou+qMMGQzljgXGZccoNWjFx1MDAxNtCZ50RRXnCZY9pcXIUqYLg7XHUwMDA1XGaRikrBlC1yV1x1MDAxNPAyXHUwMDE4XG6JXHUwMDEzaszTYDhGoJWSTIkpXHUwMDA0OsdcdTAwMGJW3FJBYuZeT2C0tOPVRCMuv3yu7VxiXHUwMDE2bbDrr/Zk5dqsXHUwMDFlbfQng7dkRCG4qaHCOmnJXHUwMDE4WVx1MDAxY02Kjqtb6NBcdTAwMTTNbVx1MDAwZd5cXFE0oOj74i0kt7xgsYNJQ1x1MDAxOONWoVShhjBTOSu/XHUwMDE5vPemgbdhyFNFcUJcdTAwMDKUWllcbjibMr1cdTAwMDY447FcYumfyq/B2lJ0srh+cnXYbG9vrVx1MDAxZldlZZ+vlOJJRZK9XHUwMDE4Rd3do7/8wFx1MDAxMWzvtlx1MDAxM/b6XW94uNVcdTAwMGVrrqFR60R4vHnfcH/630Gte+lcdTAwMTj85WUtaDry7p+eXHUwMDBlOP39kabfXHUwMDFiPeVcdTAwMTV5Ly8+QpW4M8erS1x1MDAxYnstOGt7jVx1MDAwYnPX3fWPor6XV7Il7lxmXHUwMDA3KXFulMap0VarTMhcdTAwMTeZXHUwMDFlQfpcdTAwMGKOIXE8Lc+jXHUwMDA0eipcdTAwMTQsXHUwMDE4ZVxcalhqXTqJ+WpGOKfKSGlAI5maJl2kXHUwMDA1plx1MDAwMfAzXHUwMDE17Sx5M1PkiyhcdTAwMWNKdDJ1oTczLl9cdTAwMDT5MpWgVILBl1e0Y1x1MDAxNWLlQVS52LriX1x1MDAxNr9AcLV0cfvd0jV2PFFqXHUwMDE1R8Xs/HLpOsSlUZksXGapLNFWMFxyUmh07vNZXHUwMDE4jFx1MDAwMVx1MDAwMUWtRC4oXHUwMDFj78jDXHUwMDA1v1x1MDAwMlUl+pxcdTAwMTShgnx3muzon4+WmaIl7cnhgt4gSC1cdTAwMGKzq6hcdTAwMTalcFHIMlx1MDAxOVxiOXtZXHUwMDE4neuTTtdr+bfv53AqnCnq+yedWnT2fm5cdTAwMWJ/vlwiQzzxk1RiMENB/c2G/rZ33deLn9jG8mXQrk+G/oGtVNa6Wlxilk3BXHUwMDEyljBFudDGMkNNflx0XHUwMDA1XHUwMDFkXHUwMDFhYo2LIFJOnTnNg/85IYffXGb8l9OskILLU2VF6ydcYodSU4mTTJ1cdTAwMWF+kqn8qeBf2V5b2ptbWpxcdTAwMWKGz19R1GFM36tcdDyc711cdTAwMWMtbTZb9NPX3b3WxuZieNrpTGjgXHKhllxuxDpcdTAwMDBwluHDXFxcdTAwMGJcIlGYXHUwMDE4TqJFiaL55CzEP1x1MDAwMSFcdTAwMDSaXHJcdTAwMDPozpqC0MObiX/o1KMoXHUwMDBmp1xuLDKGylPlXG6eXFyjhVx1MDAxY1NcdTAwMWXiXFxcImHjSOiqXyh9el7Bn+PuO+NcdTAwMGKlqFxmXHUwMDAy9Df/SEKLi9tr8YJez+tee69qjXOKZ6mEnlxcrG6u7zV35HZYu4u+e9tXx1x1MDAxZv1cdTAwMDLdNZjWXHUwMDEx5cVkvOqhLFx1MDAxYS1ktClOXHUwMDFi6y40XHUwMDAzxMXPUbkprSSkqjaG+VtcdTAwMWF1XHUwMDE3ZVRxbSRPRdpcdTAwMTNfnlx1MDAxOIWm1KI9tWhW+DRcdTAwMGKTv1NiaV2tlGSWJmh+mEt2f+RH0vt7heby5ChiMpfBXHUwMDE1myqR82WS3HGthULCMzvEpdLCzjI5dZ+FnIgmd8vpvpmo9ESp9zOhOa9dXHUwMDBmb1xuRvEyvPY2/EFPe0d+dHY/b/OPXHUwMDA3PT7UL75cdTAwMWVcdTAwMDarnZvrPXt30DxZ9FfaXHUwMDA1qaFcdTAwMDV6XHUwMDA1SbAm6DtcdTAwMWJFnVx1MDAwM81TvvBQsUCcyC4k5UrlOVx1MDAxMZWGuHVcdTAwMTaJ9lm51NC8YkGKStCl4lx1MDAwMslcdTAwMTUoodgr4UQvoFk+Pl+xXGL0LYVOZ+gmikWwMbFDxJWiwJ9ElGZfr1BcdTAwMTCEXHTBuGLSMCuSKKr7SEVcdTAwMTjqXHUwMDFjXG5cdTAwMTIppqbUPHq7MrGPXHUwMDFic1x1MDAwMv9r9FRKTO43jYiFbX7jKPh814ZQNM/tx53zTrB+ZlspSom6p1x1MDAxOUt82K376WxcdTAwMTJE0W3SqZHBJ1xuXHUwMDFmX2mUNVBcXCZcdOODRlx1MDAwZZzitFNpuVx1MDAxMozK3PP/XHUwMDE4fbqJ9WlGh2WU6Vx1MDAxOFx1MDAxZDl+XGbG+Y1cXFx1MDAwMVx1MDAxMZpcdCnR61x1MDAxM4JmVCSVjFx1MDAxOG05yo9wJr2Ae7nokVsh0Vx1MDAwMl1LXHUwMDE0voLgULk3/6ZcIkdV5DTJtZKBQF+cXHUwMDE1kSzGS8t6hFx1MDAwNUVcdTAwMTnwyterx1x1MDAxYupcdTAwMTFBfFx03277sLg2f1Lv7dfGmkZ6W01wabzvPZZIUcqIllx1MDAwZeBcdTAwMWMpbDpvZUCklFtcdTAwMWNCVci4wf/bglxuXHUwMDFijlRMXHUwMDE54+g9SqxcdTAwMDZWoCYo14RZVyus3Jxr80alyvTE5+dTKZxcdTAwMTEjXGaHXCL1oVl5zMlVWTGuaYX6o9A+pSS317xqhp9cdTAwMWGtXHUwMDE287f3Vlx1MDAwZbRcbjf9y1+kPirlaVx1MDAwYpQqwl1cYlx1MDAxNvlcdTAwMDVa3FRk331cXCszmlxu7K+STFxu/uhccstxXHUwMDE13zCHqCq5Wlx1MDAxOSNcdTAwMWJcdTAwMWbFflwiI6PMXHUwMDE4pCmccVx1MDAwYlQnXHUwMDAxgkErXHUwMDBlXHUwMDFh+ng4blxunEU0ucdM5H9cIlI5vlxc6YmPYKRwyfNSc25talxyPp4pguZcXEkjlVx1MDAwMVx0jnr+XCJWWYWLXozPSegnsmhcdTAwMWNcdTAwMTYrmUtkNDa7NGk5oUK4sky3dsF1fmnS1SSwXHUwMDAyt/yNct736XFTMkUluVx1MDAxNFajOslXZs7HgZBSm8GQc0pXYFu5+43+aXLTJ7DCZr39zKh+XHQvbHiuZHpcZjO89JvNdOhslFx1MDAxYz7G1rJ8MfNcdTAwMWPVMMbxdVx1MDAxZWND+tRSV5EtXHUwMDE0Negnm+y2L8Jgs7RcdTAwMDBgtDSuXHUwMDFhXCKHbMYlsSBcdTAwMTH+3OWycFFcdTAwMTDVt1x1MDAwNJ1zNpAtptGgelx1MDAwYilcdTAwMGZ2XHUwMDE2sqFZ5vhLXCL9i3k+a2RMXHUwMDE59Fx1MDAxZVnRUiWT5SpASJfzx1KU/v8qXHUwMDAwVy6s7rNQKKdVUrHXXHUwMDFi3lx1MDAxZr/SPNYrXHUwMDA1XHUwMDFjdK2U1VpR9GNkZuNHgdRcdTAwMThcclx1MDAwNM6Go1ZU5UNXSFx1MDAxOYlBViGo21x1MDAxYdLtYZZXMYxcdTAwMDA1aL5cdTAwMTQyNqbTxXSzQCRmSr1UXHUwMDEw35dcdTAwMDJA8aLUYFx1MDAwNqY0pMWAguRCPK1gfPa1y0K5oMbNOVx1MDAxOX1TLfOP5nWPpS+KUlx1MDAxMm9cdTAwMDdcdTAwMDNuSFlmJ0ouXHUwMDA1UZair6ZxKqQuXGKKa6d7qFvldZWeRbvKXCL3wjugu49erVbplOy3jISMYqlcIiNcdTAwMDH9QCNcbsLi83F4s3QrXG5KLVp1aiss3pwlzVIqp+6Tk9A3tVx1MDAxMquVsZuIPJLoZIlwNUfcWPQsWTaOblxm0cIwt8wtXHUwMDA0N1wiX1x1MDAxZupWX+NFWYmmQODUJaBLxdGBXGIltLBui3TOaMJcdTAwMTLeVMuoatl4tmaxXGa0XHUwMDAxla9cdTAwMGZ1iiVfKp5kJOA0u73GZqc+o1LFYlx1MDAxNHH7qVx0ozlqXHUwMDBmy0b8IVx1MDAwNegsKY2EzVx1MDAwMHWF0I/drVxc6uPWnLz/XHUwMDFhRVVcdTAwMTY8XHUwMDFlv8Hm3NOCx9K41Fx1MDAwZdRcdTAwMWPCcic8I7NB0NfBf9Tt3KLcLnj5OP/sRo9te/9uu75yeL3u6Vx1MDAwZlcgO+2lheW8Pi3d14ZcdTAwMWFcdTAwMTckXHUwMDE2XHUwMDE2Jd9YJrI61VpiXHUwMDAwT3I7+HPGn7avXHJlXHUwMDA2XXhr3UZkXHUwMDAyKfbbvjYlXHUwMDFhVXvFXHUwMDFhtSiajFMm3d6kJZWgpa//kOC2cpS6wljSoHbdrWxPXHUwMDEzTVx1MDAxZSPUmyxcXLysrVx1MDAxZnX2lvtcdTAwMTdf9y6/2aVjOsmSiNtcdTAwMDSbKOHSq9xG91LmfFx1MDAwZkYsTpTFkWNSXHUwMDE1ZS2ySXbWnSaO8XttXHUwMDFhoVuTizDFaVBmpN4mtSdMfiuJxK1QXHUwMDEy1ZWCXG4jXHUwMDE2VW5cbuO2XG7w6/3IW9t6RfVZpT2vZjmkv8/aatur7V9fIJFfvbnd2l2dqDpcdTAwMGKNM6KPO1x1MDAxM1x1MDAwZS7VzmZWQ5hcdTAwMTGEu9przqyR2lx1MDAxNCRcIrO4fpujoCH/XHUwMDAxXHJFXHUwMDAxXHUwMDA1Zlx0qlxma1x1MDAwNHXvM1x1MDAxMTBNROE3g/jpXHUwMDE0XHUwMDEwV0ClXHUwMDExKl+1MOjLmFx1MDAxN1xcIMZccoCusJjh3k5BelxmnoDxwS3/uK9cdTAwMDZcbprut1dU2jS++5VUM43fJGf80qfLiJVcIn4hlU7v4DSw35SO8NGCXHJrXHUwMDE1cVx1MDAxNZrIM4FcdTAwMGKtUlVcdTAwMGJcdNTBvVDITVxid1x1MDAxNTNGTpMp93vx0bNipE/h4aPj6TZhMEX4T4eGcy+qYq5cdTAwMTB3hrZ9q3bFXHUwMDEzxZhyjd0xqFx1MDAxYty+6+nLXHUwMDE3uNtQRFxuytyGLUKnX1tXfL9cdTAwMDUmXGJ6XHUwMDAzSOsl4473wkgsciEv8slcdTAwMWTfZe78fFx1MDAxN378XHUwMDBlcHNPc+E1Q/ONalx1MDAwMSxcdTAwMWFoK0eqKoCgV6kpSGZcdTAwMDHVXHUwMDA2oFx1MDAwNsg93+y68P291r74sFx1MDAwMt5xfUt/3TzYO+tcdTAwMWVcdTAwMTfsh1m+Na2r8ORuo1x1MDAwM/fCXHUwMDE5yFx1MDAxNK9TQJdcdTAwMDdcdTAwMTBPkiHx0elXVkzjw2tJXHUwMDE4XGLtVordT/GmM8t0pj9cdTAwMDU7Msq6l1wiXHUwMDE2akdXdlWmXHUwMDFlJVg58o7PirSjXHUwMDE1LFx1MDAxNVx1MDAxM3+WXHUwMDBmXHUwMDBmYm356LNe713umN7x1s3dhv5cdTAwMTZOyPeVq/M0UqDHrbNhfkdcdTAwMTBcXNG+kJpTR6tcbraBXHUwMDE0MVwiXFx+pJW8MMPxzaGfWJ7Pp3Ho3cvK0DcvSnDSdFxchaGIM3uqlmdjIfVcdTAwMDauXG78+V2vVUj1X4VLP9L5arz6UJ/Xulx1MDAwN99N4/bm5DzqXXT6wW5BXHUwMDAyUiHKhUtLXHUwMDFmLOVorbPvq5SEIzeiyFx1MDAxZYWgnOaZ/iRePWfIXGY0+lxuSI2kTe9g/Vx1MDAwNvO5UZhP89JKQa1cdTAwMDZlis1cdTAwMTbIUrOF3FS78peqY89cdTAwMWHQJ3xWfVu/68fbkL0mPz7X5Up89/HbyI/z3SnOXHUwMDA10eiwKMMst5Ddv1m7rGXliqbAXHUwMDAw51x1MDAwNfs3K1x1MDAwNL3bZE1Lrd2rplxulue5tFx1MDAwNOm/ditcdTAwMWZcdTAwMTb1XHUwMDAzm62M5VlcIqIl+yROtWNA/Fx1MDAwZbvC+Hx6Q4js7omauZdcdTAwMThWXHUwMDFlupv6JVx1MDAwNMPD1SZcdTAwMTSiXGJqY9x+OlxuKPpWo867c7gkqlx0QFx1MDAxOUbjw+Rj95NEUce90UG3QihlRnzbhbzEJzd8l7nx83338e9JmXua71x1MDAwZcYyirbaXG7OUItm199Hm2k+U2omfPd39+M8X+t09lwilMzhKM5f+97Nh7wu+Vcr/rhay/hcdJxcdTAwMWXxYlxi/nj3439xXHUwMDE5mlx1MDAxMyJ9fastcsfastcs-eigerFastCSInteractiveShellevent_loop: EventLoopcontroller: Controllertransports: list[Transport]build_api()run()EigerControllerframes: AttrRW[int]exposure: AttrRW[float]captured: AttrR[int]arm: Commandtrigger: Commanddisarm: Commandpv_prefix: strui_path: PathEPICS CA Transportconnect(ControllerAPI)serve()PVsdbl()EigerAttributeIOupdate()send()EigerAttributeIORefuri: str diff --git a/docs/index.md b/docs/index.md index 730b3fdc1..cc76efc79 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,21 @@ html_theme.sidebar_secondary.remove: true :end-before: