diff --git a/.gitignore b/.gitignore index 8f3fdc264..cdae377b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /*bob +# GUI files emitted by the example IOCs in tests/ +/opis/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/claude.md b/claude.md new file mode 100644 index 000000000..65c11785b --- /dev/null +++ b/claude.md @@ -0,0 +1,117 @@ +# CLAUDE.md + +Conventions for this repository. Follow these when writing or reviewing code and tests. + +## Helper functions and exceptions + +- A helper function must **not** be passed a parameter (e.g. `kind`) whose only purpose + is to be interpolated into the message of an exception it raises. That parameter + exists purely to serve one or more call-sites' error-reporting needs, which means the + helper is doing the call-site's job for it. +- If different call-sites need different exceptions raised (different types and/or + different messages), do not thread a parameter into the helper to cover every case. + Instead, let the helper raise its own plain/generic exception with no caller-supplied + wording, and have each call-site `catch` it and `raise ... from ...` with the + type/message it actually needs. + + ```python + # Bad: helper takes `kind` purely to phrase its own exception message + def _check_positive(value: float, kind: str) -> None: + if value <= 0: + raise ValueError(f"{kind} must be positive, got {value}") + + _check_positive(period, kind="period") + + # Good: helper raises a plain exception; call-site adds whatever context it needs + def _check_positive(value: float) -> None: + if value <= 0: + raise ValueError(f"must be positive, got {value}") + + try: + _check_positive(period) + except ValueError as e: + raise ConfigError(f"invalid period in trigger config: {e}") from e + ``` + +- A helper function must **not** be passed a parameter like `expected` or `skip` that + tells it about the *arity or shape of the call-site* (e.g. "how many items did you + expect", "should this check be skipped"). Parameters like these are a sign that the + check itself belongs in the caller, not the helper. Move the check up: + + ```python + # Bad: helper is making a decision that belongs to the caller + def _check_length(items, expected=None): + if expected is not None and len(items) != expected: + raise ValueError(...) + + # Good: caller owns the decision, helper just does the one thing it's for + if len(items) != expected: + raise ValueError(...) + _check_length(items) + ``` + + Rule of thumb: a helper's parameters should describe *what it's being asked to + validate/produce*, never *whether/how the caller wants it validated*. + +## Tests: `pytest.raises` + +- The `with pytest.raises(...):` block should contain the **minimal code that raises + the exception** — ideally a single line, and ideally just the call under test. +- Any setup needed to *put the system in a state* where that call will raise must + happen **outside** and **before** the `pytest.raises` block, not inside it. + + ```python + # Bad: setup is inside the raises block + with pytest.raises(ValueError): + controller = Device() + controller.configure(bad_value) + + # Good: setup happens first, only the failing call is inside the block + controller = Device() + with pytest.raises(ValueError): + controller.configure(bad_value) + ``` + + This keeps the assertion precise: if setup itself started raising unexpectedly, the + test should fail with an ordinary traceback, not be masked as a (possibly + coincidental) pass inside `pytest.raises`. + +## Tests: no irrelevant lines + +- Every line in a test should be there because it affects the test's outcome, given + what the test's name says it's checking. If removing a line wouldn't change whether + the test passes or fails, it doesn't belong. +- Before adding or keeping a line in a test, check it against the test name: does this + line change the behavior being verified? If not, delete it. + + ```python + # Bad: post_initialise() doesn't affect this test's assertion + def test_matching_type_hint_is_satisfied_by_the_decorated_attribute(): + class Device(Controller): + label: AttrR[str] # pyright: ignore[reportRedeclaration] + + @attr + async def label(self) -> str: + return "x" + + controller = Device() + controller.post_initialise() # irrelevant — remove + + assert isinstance(controller.label, AttrR) + + # Good + def test_matching_type_hint_is_satisfied_by_the_decorated_attribute(): + class Device(Controller): + label: AttrR[str] # pyright: ignore[reportRedeclaration] + + @attr + async def label(self) -> str: + return "x" + + controller = Device() + + assert isinstance(controller.label, AttrR) + ``` + + This keeps tests readable as documentation: every line is evidence for the claim in + the test's name, not incidental noise carried over from copy-pasting another test. diff --git a/docs/conf.py b/docs/conf.py index 3ee2ad966..0325064f3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,6 +89,9 @@ ("py:class", "p4p.nt.enum.NTEnum"), ("py:class", "p4p.nt.ndarray.NTNDArray"), ("py:class", "p4p.nt.NTTable"), + # httpx and fastapi don't have intersphinx mappings + ("py:class", "httpx.AsyncBaseTransport"), + ("py:class", "fastapi.applications.FastAPI"), # Problems in FastCS itself ("py:class", "BaseController"), ("py:class", "AttrIOUpdateCallback"), @@ -98,9 +101,12 @@ ("py:class", "fastcs.logging._graylog.GraylogStaticFields"), ("py:class", "fastcs.logging._graylog.GraylogEnvFields"), ("py:obj", "fastcs.control_system.build_controller_api"), - ("docutils", "fastcs.demo.controllers.TemperatureControllerSettings"), + ("docutils", "fastcs.demo.temperature_attr.TemperatureControllerSettings"), # TypeVar without docstrings still give warnings ("py:class", "strawberry.schema.schema.Schema"), + # A ParamSpec gets no target of its own, so `Command[P, T]` renders a + # reference to a bare `P` that resolves to nothing + ("py:class", "P"), ] nitpick_ignore_regex = [ ("py:class", r"fastcs.*.DType_T"), diff --git a/docs/explanations/controllers.md b/docs/explanations/controllers.md index 2378144d2..30a09da0a 100644 --- a/docs/explanations/controllers.md +++ b/docs/explanations/controllers.md @@ -28,21 +28,21 @@ lifecycle, if required. ### 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 +attributes whose `getter` is wrapped in `Polled`, 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")) + temperature = AttrR(float, units="degC") + setpoint = AttrRW(float, units="degC") async def connect(self): self._client = await DeviceClient.connect(self._host, self._port) @@ -72,7 +72,7 @@ controller also has connection logic, the parent must invoke it explicitly: ```python class ChannelController(Controller): - value = AttrR(Float()) + value = AttrR(float) async def connect(self): ... @@ -107,7 +107,7 @@ from fastcs.controllers import Controller, ControllerVector class ChannelController(Controller): - value = AttrR(Float()) + value = AttrR(float) class RootController(Controller): @@ -154,7 +154,7 @@ distinct components with different types or roles. `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. +sub controllers, including type hint introspection. `BaseController` is public for use in **type hints only**. It should not be subclassed directly when implementing a device driver. Use `Controller` or `ControllerVector` diff --git a/docs/explanations/datatypes.md b/docs/explanations/datatypes.md index c614deb1d..707286826 100644 --- a/docs/explanations/datatypes.md +++ b/docs/explanations/datatypes.md @@ -1,81 +1,83 @@ # Datatypes -FastCS uses a datatype system to map Python types to attributes with additional -metadata for validation, serialization, and transport handling. +An attribute's datatype is a **python type**. Everything else that describes the +attribute - precision, units, limits, array shape - is **metadata**, passed as +keyword arguments and held on the attribute as `attr.meta`. + +```python +from fastcs.attributes import AttrRW + +temperature = AttrRW(float, precision=3, units="degC") +``` + +There is no `DataType` object to construct and no wrapper to unwrap: `attr.dtype` +is `float`, and `attr.meta` is a plain typed dict. ## Supported Types -FastCS defines `DType` as the union of supported Python types: +FastCS defines `DType` as the union of supported python types: -:::{literalinclude} ../../src/fastcs/datatypes/datatype.py +:::{literalinclude} ../../src/fastcs/datatypes/types.py :start-at: "DType = (" :end-at: ")" ::: -Each has a corresponding `DataType` class. - ## Scalar Datatypes -### Int and Float +`int`, `float`, `bool` and `str` are used directly. Which metadata each accepts is +given by its `*Meta` typed dict: -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:" -::: +| Datatype | Metadata | +| -------- | ----------------------------------------------------- | +| `bool` | `description`, `group` | +| `int` | `description`, `group`, `units`, `limits` | +| `float` | `description`, `group`, `units`, `limits`, `precision` | +| `str` | `description`, `group`, `length` | -### Bool +`precision` is the number of decimal places a float is rounded to and displayed +with; it defaults to 2. `length` truncates a string during validation, and is +also a hint to transports sizing their records - the EPICS CA transport uses it +for string waveform records. -Maps to Python `bool`. Initial value is `False`. +The constructors are overloaded per datatype, so metadata a datatype has no use +for is a type error rather than a field silently ignored: -:::{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 -::: +```python +AttrRW(float, precision=3) # fine +AttrRW(str, precision=3) # type error, and raises at construction +``` ## 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: +An `enum.Enum` subclass is used directly as the datatype; the choices come from +the class, so there is no metadata to give: ```python -class DetectorStatus(StrEnum): +import enum +from fastcs.attributes import AttrR + +class DetectorStatus(enum.StrEnum): Idle = "IDLE_STATE" Running = "RUNNING_STATE" Error = "ERROR_STATE" + +status = AttrR(DetectorStatus) ``` -Clients will see the choices as `["Idle", "Running", "Error"]`. +:::{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. For the enum above, clients 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"})) +DetectorStatus = enum.Enum( + "DetectorStatus", {"Run Finished": "RUN_FINISHED", "In Progress": "IN_PROGRESS"} +) ``` Clients will see the choices as `["Run Finished", "In Progress"]`. @@ -83,91 +85,94 @@ Clients will see the choices as `["Run Finished", "In Progress"]`. ## Array Datatypes -### Waveform +### Array1D -For homogeneous numpy arrays (spectra, images): +For homogeneous numpy arrays. The element type rides on the datatype itself, and +the maximum shape is metadata: -:::{literalinclude} ../../src/fastcs/datatypes/waveform.py -:pyobject: Waveform +:::{literalinclude} ../../src/fastcs/datatypes/types.py +:start-at: "Array1D: TypeAlias" +:end-before: "class Table" ::: -Validation ensures the array fits within the declared shape and has the correct dtype. +```python +import numpy as np +import numpy.typing as npt +from fastcs.attributes import AttrR +from fastcs.datatypes import Array1D + +spectrum = AttrR(Array1D[np.float64], shape=(1000,)) +counts = AttrR(npt.NDArray[np.int32]) # numpy's own spelling, default shape +image = AttrR(np.ndarray, array_dtype=np.uint16, shape=(1024, 1024)) +``` + +Validation ensures the array fits within the declared shape and has the correct +element type. `shape` defaults to `(2000,)`. ### Table For structured numpy arrays with named columns: -:::{literalinclude} ../../src/fastcs/datatypes/table.py +:::{literalinclude} ../../src/fastcs/datatypes/types.py :pyobject: Table ::: -The `structured_dtype` field is a list of `(name, dtype)` tuples following +The `structured_dtype` metadata is a list of `(name, dtype)` tuples following numpy's structured array conventions. -## Validation +## Limits -### Built-in Numeric Validation +Numeric limits are nested rather than flat, in four categories aligned with the +bluesky event-model: -`Int` and `Float` datatypes support min/max limits and alarm thresholds: +:::{literalinclude} ../../src/fastcs/datatypes/limits.py +:pyobject: NumericLimits +::: ```python from fastcs.attributes import AttrRW -from fastcs.datatypes import Int, Float +from fastcs.datatypes import Limits, NumericLimits -# Integer with bounds -count = AttrRW(Int(min=0, max=100)) - -# Float with units and alarm limits -temperature = AttrRW(Float( +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 -)) + limits=NumericLimits( + control=Limits(-273.15, 1000.0), # what it may be driven to + display=Limits(0.0, 500.0), # what it is shown as spanning + alarm=Limits(-50.0, 200.0), # outside this it is in alarm + ), +) ``` -#### 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" -``` +Only the **control** range rejects values. Display, alarm and warning are served +to clients - EPICS `LOPR`/`HOPR` and `DRVL`/`DRVH`, Tango's attribute +properties, the PVA display and alarm structures - but do not constrain a write. -### String Length +## Validation -Limit the display length of strings: +### Numeric limits ```python -from fastcs.datatypes import String +from fastcs.datatypes import Limits, Meta, NumericLimits, validate_value -# Limit display to 40 characters -status = AttrR(String(length=40)) -``` +meta = Meta(limits=NumericLimits(control=Limits(0.0, 100.0))) -:::{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. -::: +validate_value(float, meta, 50.0) # Returns 50.0 +validate_value(float, meta, -10.0) # Raises ValueError: "Value -10.0 is less than minimum 0.0" +validate_value(float, meta, 150.0) # Raises ValueError: "Value 150.0 is greater than maximum 100.0" +``` ### Type Coercion -All datatypes automatically coerce compatible types: +Values are coerced to the datatype: ```python -from fastcs.datatypes import Int, Float +from fastcs.datatypes import Meta, validate_value -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) +validate_value(int, Meta(), "42") # Returns 42 (str -> int) +validate_value(int, Meta(), 3.7) # Returns 3 (float -> int, truncated) +validate_value(float, Meta(), "3.14") # Returns 3.14 (str -> float) +validate_value(float, Meta(), 42) # Returns 42.0 (int -> float) ``` ### When Validation Runs @@ -175,86 +180,60 @@ float_type.validate(42) # Returns 42.0 (int -> float) 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 +2. **Set request**: `await attr.set(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 +from fastcs.datatypes import Limits, NumericLimits -attr = AttrRW(Int(min=0, max=10), initial_value=5) +attr = AttrRW(int, limits=NumericLimits(control=Limits(0, 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 +# Sets are validated +await attr.set(3) # OK +await attr.set(-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 +Metadata itself is validated when the attribute is built, so a field that the +datatype has no use for fails fast even when it arrived without a static check - +from a declarative extras object, say: -## 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 -::: +```python +AttrR(str, precision=3) +# TypeError: 'precision' is not valid metadata for str attribute - valid fields +# are description, group, length +``` -Subclasses can override this to add validation logic. The pattern is +## Transport Handling -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: +Transports are responsible for serializing values appropriately for their +protocol, and each must handle every supported datatype. They dispatch on +`attr.dtype` and read what they serve from `attr.meta`: -:::{literalinclude} ../../src/fastcs/datatypes/_numeric.py -:pyobject: _Numeric.validate -::: +- Scalars (`int`, `float`, `bool`, `str`) serialize directly +- Enum values are typically serialized as integers (index) or strings (name) +- Arrays and tables are serialized as lists or protocol-specific array types -### Overriding `equal()` +An array and a table are both held as `np.ndarray`; what separates them is that a +table's metadata names its columns, so a transport that needs to tell them apart +checks for `structured_dtype` in `attr.meta`. -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()`: +## Adding a Datatype -:::{literalinclude} ../../src/fastcs/datatypes/waveform.py -:pyobject: Waveform.equal -::: +A datatype is a python type in `DType`, so adding one means widening that union +and teaching the pieces that dispatch on it: -### Transport Compatibility +1. Add the type to `DType` in `fastcs.datatypes.types`, and to `resolve_datatype` +2. Add a `*Meta` typed dict for the metadata it accepts, and map the datatype to + it in `meta_class_for` +3. Handle it in `validate_value`, `default_value` and `values_equal` +4. Add an overload to each of `AttrR`, `AttrW` and `AttrRW` so its metadata is + statically checked +5. Handle it in each transport -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. +Metadata alone needs much less: a new field on an existing `*Meta` is picked up +by `validate_meta` automatically, and only the transports that serve it change. diff --git a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md new file mode 100644 index 000000000..f9c2ce865 --- /dev/null +++ b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md @@ -0,0 +1,182 @@ +# 13. Declarative/Procedural Split and ControllerFiller + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) + +## Status + +Proposed + +## Context + +FastCS currently has two mechanisms for declaring the shape of a `Controller`: + +1. **Class-scope `Attribute` instances** (`ramp_rate = AttrRW(Float(), io_ref=...)` + assigned directly in the class body). `BaseController._bind_attrs` + (`src/fastcs/controllers/base_controller.py`) walks the MRO, finds these, and + `deepcopy`s each one onto the instance so that multiple instances of the same + `Controller` subclass do not share mutable state. +2. **Bare type hints** (`frames: AttrRW[int]`) validated, not created, by + `HintedAttribute` (`src/fastcs/attributes/hinted_attribute.py`) via + `_find_type_hints`/`_validate_type_hints`. The actual `Attribute` must be + constructed and assigned by the developer, normally in an `initialise()` + override that introspects a device. + +ophyd-async has the equivalent split (`Device` class-body hints vs. `__init__` +procedural construction, see `docs/explanations/declarative-vs-procedural.md`), +but only one mechanism for the declarative half: hints always *create* children, +provisioned by a `DeviceConnector`-owned `DeviceFiller` +(`ophyd_async/core/_device_filler.py`) either immediately or later via +connect-time introspection. There is no ophyd-async equivalent of FastCS's +class-scope instance style, and there cannot be — a `Signal`'s backend depends on +which `DeviceConnector` the owning `Device` is constructed with, so the backend +cannot be known until connect/construction time chooses the connector. + +Our own downstream drivers show why the FastCS class-scope-instance mechanism is +already the minority case in practice, not the norm: + +- `fastcs-eiger` mixes class-body instances (`trigger_exposure = AttrRW(Float())`) + with bare hints filled by REST-API introspection in `initialise()` + (`eiger_detector_controller.py`) — i.e. it already wants one unified mechanism. +- `fastcs-secop`, `fastcs-PandABlocks`, and `fastcs-catio`'s dynamic path build + **all** of their attributes from wire/YAML-derived data at `initialise()` time; + none of them use class-scope instances at all. + +The `deepcopy` half of `_bind_attrs` exists solely to make class-scope instances +safe to reuse across `Controller` instances. It is fragile (IO objects, bound +callbacks, and connections do not always survive a deepcopy cleanly) and costs +construction time on every instantiation, for a feature none of our real-world +introspecting drivers use. + +## Decision + +Adopt a single declarative mechanism, matching ophyd-async: **class body = +declarations + decorated behaviour; instance scope = construction with data.** +Concretely: + +- Remove class-scope `Attribute` **instances** entirely. `AttrRW(getter=..., + setter=...)` may no longer be assigned directly in a class body. +- Remove the deepcopy half of `_bind_attrs`. Method binding for `@command`/ + `@scan` — and the new `@attr`/`@x.setter` sugar + ([ADR 18](0018-attr-decorator-sugar.md)) — is unaffected and stays, since it + does not require deepcopy: it binds a method to `self` at construction time + via the `UnboundCommand`/`UnboundScan` machinery rather than deepcopying a + prototype. +- Remove `HintedAttribute` and `_validate_type_hints`/`_validate_hinted_*` as + a *separate* validation-only pass. Their job — "this hinted child must + exist with the right type after initialisation" — is subsumed into the new + `ControllerFiller`. +- Introduce `ControllerFiller`, a direct structural port of ophyd-async's + `DeviceFiller`. It scans class-body type hints (`AttrR/W/RW[T]`, + `Command[P, T]` — see [ADR 15](0015-typed-commands.md) — and nested + `Controller` / `ControllerVector[T]`), creates children **unfilled**, and + tracks filled/unfilled state per child. `check_filled(source)` raises, + listing by name, anything a `Controller`'s `initialise()` promised via a hint + but did not provision. +- `ControllerFiller` yields `(child, extras)` for each created child — the + `extras` being anything else found in an `Annotated[...]` hint — so that + protocol libraries (a future SCPI package, for example) can define their + own extras vocabulary the same way ophyd-async's `PvSuffix`/`TangoPolling` + do. Core FastCS defines **no** extras vocabulary for 1.0 (decision 3 of + #388). +- When a child is filled from an `Annotated[AttrRW[T], extras]` hint, the filler + **runtime-validates** the metadata the extras carries (a `FloatMeta`, or a + protocol object's `.meta` such as `SCPIParam(...).meta`) against the datatype + `T` — e.g. `precision` supplied for a `str` raises. This is the runtime + counterpart to the static `Unpack[FloatMeta]` check on the procedural `Attr*` + constructors (see [ADR 14](0014-attribute-io-rw-rework.md)). + +Two patterns follow, and the class body distinguishes them. + +**Procedural, no hint** — the value is fully constructed in `__init__`, so it +needs no class-body declaration at all. Per-attribute IO is a `getter`/`setter` +pair of callables ([ADR 14](0014-attribute-io-rw-rework.md)); the datatype is +inferred from the getter's return annotation: + +```python +class TemperatureRampController(Controller): + def __init__(self, index: int, conn: IPConnection) -> None: + super().__init__() + suffix = f"{index:02d}" + + async def get_start() -> int: + return int(await conn.send_query(f"S{suffix}?\r\n")) + + async def set_start(value: int) -> None: + await conn.send_command(f"S{suffix}={value}\r\n") + + # datatype int is inferred from get_start's return annotation + self.start = AttrRW(getter=Polled(get_start, period=0.2), setter=set_start) +``` + +**Declarative hint + filler** — the value is *promised* by a hint; the +`ControllerFiller` (run from `Controller.__init__`) creates it as an +**unfilled** `Attribute` so it **exists as soon as `__init__` returns**, and +`initialise()` later *fills* it (provisions the getter/setter + metadata) by +introspection: + +```python +class OdinDetector(Controller): + frames: AttrRW[int] # created UNFILLED by the filler in __init__; + # self.frames EXISTS after __init__, before initialise() + + async def initialise(self) -> None: + # introspection FILLS the already-created hinted attrs (getter/setter + + # metadata), and may add wholly-undeclared dynamic attrs (no hint) + for name, spec in await self._query_parameter_tree(): + self.filler.fill_attribute(name, spec) # validates meta vs datatype + self.filler.check_filled() +``` + +**The rule** (identical to ophyd-async's rule for `Signal`s): *at the end of +`__init__`, any Attribute referenced in code — and therefore carrying a type +hint — must exist* (the filler guarantees this for hinted children by creating +them unfilled during `__init__`). Only `__init__` is serial; `initialise()` +may then run in parallel across controllers. + +Introspecting controllers keep working as today's `initialise()` + +`add_attribute` pattern. The fully-dynamic case — where the *set* of +attributes is not known until a network round-trip completes +(`fastcs-PandABlocks`, `fastcs-secop`) — needs `ControllerFiller` to fill +children that were never hinted at all. This is **no harder than ophyd-async +already supports**: `Device(connector=PviConnector(prefix))` fills a whole +`Signal` tree from introspection with no hints required, and `ControllerFiller` +mirrors that `DeviceFiller` path directly. + +## Consequences + +- Every existing driver using class-scope `Attribute` instances needs + migration to bare hints + `__init__`/`initialise()` construction — see the + Example 1 (`DRAFT: Example 1 — IORef temperature controller`) and Example + 2 (`DRAFT: Example 2 — introspectable Eiger-style controller`) sub-issues + of #388, and the corresponding downstream repo work. +- `Controller.__init__` no longer needs to run `_bind_attrs`, simplifying + construction and removing a source of deepcopy-related bugs. +- Static typing improves: a bare hint `frames: AttrRW[int]` is exactly the + type a type checker sees, with no deepcopy step that could plausibly + change it. +- `ControllerFiller` becomes a new stable, documented surface — see + [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) for how + it interacts with the stable `ControllerAPI` surface consumed by the + embedded ophyd-async connector ([ADR 19](0019-embedded-ophyd-async-connector.md)). + +## Questions resolved in review (#402) + +1. **Must `ControllerFiller` support "no hints at all"?** Yes — it must build + the whole attribute tree from introspected data with nothing declared in the + class body (`fastcs-PandABlocks`, `fastcs-secop`), exactly as + `DeviceFiller` does for a `PviConnector`. +2. **Is `fastcs-catio`'s runtime `type(...)` class-building supported?** No. A + bare `Controller` instead allows attributes to be added onto it from the + outside — which is exactly what the fillers do — so catio moves to + instance-level dynamic attribute construction. +3. **Is there a sibling-ordering mechanism?** No. The rule "any hint-referenced + Attribute must exist by the end of `__init__`" makes `initialise()` + parallelisable; sibling dependencies are an `initialise()` implementation + detail (call `super().initialise()` first). +4. **Are `Optional[X]` hints supported?** Yes — `check_filled` treats an + optional hint as not-required. +5. **Do we follow `DeviceFiller`'s names?** Follow its *structure*, not its + names. Architectural similarity matters; method names match only where + FastCS's vocabulary (`Attribute` vs `Signal`) makes them fit. diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md new file mode 100644 index 000000000..5911b3ec8 --- /dev/null +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -0,0 +1,404 @@ +# 14. Per-Attribute IO as getter/setter Callables + +Date: 2026-07-20 (revised 2026-08-03, after the #412 review) + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 9](0009-handler-to-attribute-io-pattern.md), [ADR 12](0012-attribute-io-naming-convention.md), +[ADR 18](0018-attr-decorator-sugar.md), [ADR 20](0020-transport-setpoint-mirroring.md) + +## Status + +Proposed + +## Context + +[ADR 9](0009-handler-to-attribute-io-pattern.md) split the old `Handler` +pattern into `AttributeIO` (behaviour, one instance per `Controller`, +shared across attributes) and `AttributeIORef` (per-attribute resource +specification, dispatched to the right `AttributeIO` by type at +`_connect_attribute_ios` time). Its sole structural justification was that +class-scope `Attribute` instances are created before `__init__` runs, so +they cannot close over a live connection — the `AttributeIORef` only needed +to carry inert data (a register name, a URI) until the matching +`AttributeIO` was found by type at `post_initialise()`. + +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md) removes +class-scope `Attribute` instances entirely. Every attribute is now +constructed procedurally, in `__init__` or `initialise()`, where a live +connection is already in scope. The situation `AttributeIORef` was invented +to solve no longer exists. + +We also verified downstream that nothing outside FastCS consumes `io_ref` or +the IO registry directly — every consumer bottoms out in +`attr.set_on_put_callback(io.send)` / `attr.set_update_callback(io.update)` +(`base_controller.py:_connect_attribute_ios`), i.e. the ref/registry split is +a dispatch layer over callbacks that already exist as plain methods. + +The dispatch-by-type registry has real costs in our downstream drivers: + +- `fastcs-catio` registers **three** separate `AttributeIO`/`AttributeIORef` + pairs on one `Controller` (`ios=[poll_io, symbol_io, coe_io]`) purely so + each attribute's `io_ref` type can select the right one at + `_connect_attribute_ios` time — indirection that a direct per-attribute + callable removes outright. +- `fastcs-secop` needs a private escape hatch, + `attr._call_sync_setpoint_callbacks`, to push setpoint echoes from its + `send()` implementation, because the current `AttributeIO.send` signature + has no sanctioned way to do this — flagged in-code as pending a public API. +- `fastcs-PandABlocks`'s `UnitsIO.send` mutates a *sibling* attribute's + datatype (`attribute_to_scale.update_datatype(...)`), reaching outside the + attribute it was invoked for — a pattern the new IO shape should not make + harder, even though it stays an edge case. + +## Decision + +Delete `AttributeIO` and `AttributeIORef` and their whole dispatch machinery. +Per-attribute IO is supplied as plain **`getter`/`setter` callables** on the +`Attr*` constructors — which *is* the procedural spelling of the `@attr` +decorator ([ADR 18](0018-attr-decorator-sugar.md)): + +- `AttrR(getter=g)`, `AttrW(setter=s)`, `AttrRW(getter=g, setter=s)`. Access + mode is enforced by **which parameters exist** (an `AttrR` has no `setter`), + so there is no IO class hierarchy and no abstract-method enforcement to + carry — and `getter=` on a read-only attr is honest where `io=` was a false + friend. +- **The getter returns the value; the framework applies it** — + `getter() -> T | Update[T]` — instead of the old imperative `io.update(attr)`. + Imperative / multi-attribute periodic logic stays with `@scan`, which is + *why* per-attribute IO shrinks to "one value in / out". +- The **setter** returns `None | T | Update[T]`: `None` = fire-and-forget + (readback catches up on the next poll / the setpoint cache); a returned value + is the device's *accepted* value (a clamp or echo) and updates the readback + + the setpoint cache immediately — the sanctioned replacement for + `fastcs-secop`'s private `_call_sync_setpoint_callbacks`. +- **Datatype is optional when a getter/setter is given** — inferred from the + getter's return annotation (or the setter's parameter), unwrapping `Update[T]` + to `T`, so `AttrR(getter=g)` yields `AttrR[float]` with no restated type + (parity with `@attr`). Only the bare python type is optional; + `precision`/`units`/… stay explicit kwargs, and the per-datatype + `Unpack[*Meta]` static check keys off the inferred return type. Not inferable + (`-> Any`, an unannotated lambda) ⇒ the positional datatype is required + (fail-fast at construction). +- Soft is now simply the *absence* of a getter/setter (`AttrRW(float)` + self-wires setpoint→readback as before, the analogue of ophyd-async's + `soft_signal_rw`); the old `io=None` sentinel is gone. +- The declarative/filler path lowers to the **same** getter/setter (a + `SCPIController`'s filler builds the callables from a `SCPIParam`). getter and + setter are where the old `_connect_attribute_ios` wiring now lives, so + transports and the embedded connector are unaffected. + +`attr` is a **decorator only** (`@attr` / `@attr(precision=3)` + +`@voltage.setter`, [ADR 18](0018-attr-decorator-sugar.md)); there is no +free-function `attr()` factory — the procedural spelling is `AttrR`/`AttrRW` +directly. + +```python +class TemperatureRampController(Controller): + def __init__(self, index: int, conn: IPConnection) -> None: + super().__init__() + name = f"R{index:02d}" + + async def get_ramp_rate() -> float: + return float(await conn.send_query(f"{name}?\r\n")) + + async def set_ramp_rate(value: float) -> None: + await conn.send_command(f"{name}={value}\r\n") + + # datatype float inferred from get_ramp_rate's return annotation + self.ramp_rate = AttrRW( + getter=Polled(get_ramp_rate, period=0.2), + setter=set_ramp_rate, + units="deg", + ) +``` + +### The reading schedule travels with the getter + +There is no `poll_period` constructor argument. A getter carries its own +schedule, so the two cannot drift apart and the pair can be passed around as +one value: + +```python +self.config = AttrR(float, getter=self._get_config) # once, at connect +self.reading = AttrR(float, getter=Polled(self._get_reading, period=0.2)) # every 0.2s +self.label = AttrR(str, getter=NotPolled(self._get_label)) # never; poll() only +self.computed = AttrR(float) # soft, no getter +``` + +`Polled` and `NotPolled` take an optional getter and bind one when called, so +the same objects serve the declarative spelling in +[ADR 18](0018-attr-decorator-sugar.md) — where the getter arrives by decoration +and there is no argument to wrap — giving one vocabulary across both: + +| Schedule | Procedural | Declarative | +|---|---|---| +| Once, at connect | `AttrR(t, getter=g)` | `@attr(units="V")` | +| Every 0.5s | `AttrR(t, getter=Polled(g, period=0.5))` | `@attr(Polled(0.5), units="V")` | +| Never; `poll()` only | `AttrR(t, getter=NotPolled(g))` | `@attr(NotPolled(), units="V")` | + +**A bare getter means "read once, at connect"**, not "never read". Three +defaults were considered: + +1. *Bare = once* (chosen). Fails safe: an attribute always shows a real value, + and polling is opted into per attribute rather than being something you must + remember to switch off. +2. *Bare = never read.* Restores the pre-refactor `AttributeIORef.update_period + = None` default and makes all scheduling explicit — but fails **silently**: + an unpolled `AttrRW` sits at the datatype default and, under + [ADR 20](0020-transport-setpoint-mirroring.md), never establishes a setpoint + either, so every transport shows `0`/`""`/`False` until someone writes to it. +3. *No default; always require a wrapper.* Rejected because ADR 18 promises a + bare `@attr`, which must resolve to some schedule. A constructor that refused + to default while the decorator defaulted would reintroduce the asymmetry + these wrappers exist to remove. + +`ONCE` (`float("inf")`) survives internally as what `poll_period` reports for +the bare case, but a driver author never spells it: `Polled(getter, +period=ONCE)` would read as a contradiction, and the once-only case is the one +with no wrapper at all. `NotPolled(g)` is distinct from having no getter — the +former is still readable via `await attr.poll()` and by transports on demand, +the latter has nothing to read. + +### `Update[T]` + +`Update` is what a getter or setter returns when a bare value is not enough: + +```python +@dataclass +class Update(Generic[T]): + readback: T + timestamp: float | None = None # None ⇒ framework stamps receive-time + setpoint: T | None = None # None ⇒ leave the cached setpoint alone +``` + +- `readback` is the value, named for the cache it feeds. +- `setpoint` is how a device that reports its own setpoint drives one, and how + a setter distinguishes "the device clamped the value it will *report*" from + "the device clamped what I *asked for*". A **bare** value returned from a + setter means both — it is equivalent to `Update(readback=v, setpoint=v)`. +- `severity` is **not** on `Update` yet; native timestamps and the severity + enum are [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)'s + scope and land with it. `timestamp` is accepted here so the field ordering is + settled, but is not yet persisted. + +### Runtime surface + +The old `get()` / `update(value)` / `put(value)` method trio is renamed and +split so that both **access mode** and **whether a call touches the device** +are legible from the member set: + +| Member | Kind | AttrR | AttrW | AttrRW | Device IO? | +|---|---|---|---|---|---| +| `.readback` | property (sync) | ✓ | — | ✓ | no (cached) | +| `.setpoint` | property (sync) | — | ✓ | ✓ | no (cached) | +| `poll()` | async method | ✓ | — | ✓ | **yes** (getter) | +| `update(value)` | async method | ✓ | — | ✓ | no (cache push) | +| `update_setpoint(value)` | async method | — | ✓ | ✓ | no (cache push) | +| `set(value)` | async method | — | ✓ | ✓ | **yes** (setter) | +| `add_readback_callback()` | method | ✓ | — | ✓ | no | +| `add_setpoint_callback()` | method | — | ✓ | ✓ | no | + +- **`.readback` / `.setpoint` replace `.value`.** Two explicitly-named cached + properties instead of one whose meaning shifted per class. Each class exposes + only the ones it has (`AttrR` has no `.setpoint`, `AttrW` no `.readback`), so + access mode reads off the surface — and the pair mirrors bluesky / + ophyd-async's `Location(setpoint, readback)` exactly, so `AttrRW` maps 1:1 + onto `locate()` and the embedded connector's `get_value`/`get_setpoint`. Both + are **read-only** properties: writes are async (validate + `await` callbacks) + and so cannot be property setters. +- **`poll()` replaces the no-arg `update()`; `update_period` → `poll_period`.** + `poll()` does a live getter read, caches it, and **returns** the value (so an + on-demand read is `await attr.poll()`, mirroring ophyd's live `get_value()`); + `poll_period` is now a read-only property reporting the schedule resolved from + the getter's wrapper, not a constructor argument. This deletes the + `set_update_callback` / `bind_update_callback` plumbing — the getter lives on + the attr and `poll()` calls it. +- **`update(value)` is now purely a cache push** — a `value` or `Update[T]` + from a `@scan`/subscription — with no device IO and no `None` sentinel. + `update_setpoint(value)` is its setpoint-side counterpart. +- **`set(value)` replaces `put()`** (the bluesky/ophyd verb): it caches + `.setpoint` immediately (decision 10a) and publishes it to the setpoint + callbacks, then runs the setter; the setter's return feeds `.readback` via + `update()`. The old `sync_setpoint=` kwarg and `_call_sync_setpoint_callbacks` + are gone. +- **The two callback registrars are symmetric.** `add_readback_callback()` + (formerly `add_on_update_callback()`) and `add_setpoint_callback()` are how + transports publish each cache. Transports must not track a setpoint of their + own — see [ADR 20](0020-transport-setpoint-mirroring.md), which also removes + the per-transport "seeding" of a setpoint display by making the first readback + on an `AttrRW` establish the setpoint. + +So `poll()`/`set()` touch the device; `.readback`/`.setpoint`/`update()` do +not. `Attribute` also loses its second generic parameter — +`Attribute[DType_T, AttributeIORefT]` collapses to `Attribute[DType_T]`, making +`AttrRW[float]` structurally isomorphic to ophyd-async's `SignalRW[float]`. + +### Datatype metadata: the `*Meta` TypedDicts + +`DataType` classes are gone ([ADR 15](0015-typed-commands.md) / +[ADR 17](0017-naming-pass.md)). The metadata they carried (precision, units, +nested limits, …) moves to a per-datatype `TypedDict` — `FloatMeta`, `IntMeta`, +`StrMeta`, `BoolMeta`, `EnumMeta`, `Array1DMeta`, `TableMeta` — and **the +resolved metadata is stored on the `Attribute` itself** (`attr.meta`), not on a +separate datatype object. Every transport/connector that read +`attr.datatype.precision`/`.units`/`.limits`/`.choices` now reads `attr.meta` +(enum `choices` come from the python type; `EnumMeta` is display-only). + +Two spellings, two validation layers: + +- **Procedural (statically checked):** the `Attr*` constructors are overloaded + per datatype so the right `*Meta` is unpacked into `**kwargs`: + + ```python + # conceptually, one overload per datatype: + def AttrRW(dtype: type[float], *, getter=..., setter=..., + **kwargs: Unpack[FloatMeta]) -> AttrRW[float]: ... + + self.temperature = AttrRW(float, precision=3, units="deg", setter=apply_temp) + # AttrRW(str, precision=3) is a static type error + ``` + + (The `dtype` positional is only needed when it cannot be inferred from a + getter/setter annotation, as above.) + +- **Declarative (runtime-checked by the filler):** + `Annotated[AttrRW[float], FloatMeta(precision=3)]` (rare) or + `Annotated[AttrRW[float], SCPIParam("P", precision=3)]` (common). Neither + ties the metadata to the `AttrRW[...]` type param statically, so + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s + `ControllerFiller` validates it against the datatype at fill time. A generic + extras object takes the **superset** `Meta` TypedDict — + `SCPIParam(param: str, **kwargs: Unpack[Meta])` (`Meta` being the union of + `FloatMeta`/`StrMeta`/… fields, all optional) — stores a `.meta`, and the + filler passes that `.meta` into the constructed `AttrRW`. + +**One spec object per declaratively-filled attribute.** A protocol extra like +`SCPIParam` is the *single place* an attribute's whole specification is +written: both the protocol binding (the command token, `"P"`) and all its +generic metadata (`description`, `precision`, `units`, limits…) via +`**Unpack[Meta]`. The filler treats that extra as the **exclusive** spec +source for its attribute — it does **not** also merge a separate +`FloatMeta`/`Meta` extra sitting on the same `Annotated[...]` hint, so there +is no precedence question to resolve. The trade this accepts is deliberate: +routing metadata through the superset `Meta` (not a per-datatype +`Unpack[FloatMeta]`) means correctness is the filler's **runtime** job, not a +static check — a separate `Annotated` extra cannot tie its `**Meta` to the +`AttrRW[...]` datatype param, and making the extra generic +(`SCPIParam[float](...)`) only forces the user to restate a type already in +the hint. So the declarative path pays for its ergonomics with runtime +validation; the filler's error must name the attribute and field (e.g. +"`precision` is not valid for `str` attribute `device_id`"). + +Naming: the extra is `SCPIParam` (a binding object you *instantiate* as an +`Annotated` extra), **not** `SCPIMeta` — the `*Meta` suffix is reserved for +the metadata TypedDicts you `Unpack` (`FloatMeta`, `Meta`), a different kind +of Python object. `SCPIParam` is a sibling of ophyd-async's +`PvSuffix`/`TangoPolling`, not of `SignalMetadata`. It is **not** part of core +FastCS (decision 3: core defines no extras vocabulary for 1.0) — it lives in a +protocol layer; the demo package ships an example `SCPIController` + +`SCPIParam` to show how a third party builds one on the filler's +`(child, extras)` mechanism. The `*Meta` module location is deferred to the +public-API-namespace decision (#406); land it provisionally until then. + +### Migration + +Migration collapses an `AttributeIO`/`AttributeIORef` pair into two callables: +the old `update`/`send` method bodies become the `getter`/`setter`, constructed +once per attribute instead of once per controller. + +```python +# Before (ADR 9 shape) +class TempIORef(AttributeIORef): + name: str + +class TempIO(AttributeIO[float, TempIORef]): + async def update(self, attr: AttrR[float, TempIORef]) -> None: + resp = await self._conn.send_query(f"{attr.io_ref.name}?\r\n") + await attr.update(float(resp)) + + async def send(self, attr: AttrW[float, TempIORef], value: float) -> None: + await self._conn.send_command(f"{attr.io_ref.name}={value}\r\n") + +ramp_rate = AttrRW(Float(), io_ref=TempIORef(name="R")) +# ... elsewhere: Controller(ios=[TempIO(conn)]) + +# After +def temp_io(conn: IPConnection, name: str): + async def getter() -> float: + return float(await conn.send_query(f"{name}?\r\n")) + + async def setter(value: float) -> None: + await conn.send_command(f"{name}={value}\r\n") + + return getter, setter + +get_ramp, set_ramp = temp_io(conn, "R") +self.ramp_rate = AttrRW(getter=Polled(get_ramp, period=0.2), setter=set_ramp) +``` + +The old ref's `update_period=0.2` becomes the `Polled(..., period=0.2)` wrapper; +a ref that left `update_period` at its `None` default becomes `NotPolled(...)` +if it really should never be read, or a bare getter if a connect-time read was +what it wanted. + +`fastcs-catio`'s three-IO-per-controller pattern becomes per-attribute +callables with no registry needed at all. `fastcs-secop`'s private +`_call_sync_setpoint_callbacks` call is replaced by a value-returning setter. + +## Consequences + +- Every driver that declared `AttributeIO`/`AttributeIORef` subclasses migrates + their `update`/`send` bodies into `getter`/`setter` callables — see the + affected §9 files in the sub-issues of #388 (`attributes/`, + `controllers/base_controller.py`, `controllers/controller.py`) and the + corresponding downstream repo issues. The migration is mechanical. +- `Attribute` loses its second generic parameter, simplifying every type + hint in downstream code (`AttrR[float, MyRef]` → `AttrR[float]`). +- Access-mode compatibility is enforced by the parameter set — an `AttrR` has + no `setter`, so there is no `_validate_io` runtime check and no way to attach + a read-only IO to a write-capable attr statically. For the dynamically-built + `Any`-typed case (`fastcs-secop`, `fastcs-PandABlocks`) a runtime check at + `post_initialise` still catches a missing setter on a write-capable attr. +- The IO no longer has a place to hang per-attribute metadata that + `fastcs-catio` used to read off `attribute.io_ref`; `attr.meta` and the + attribute's own attributes replace that access. +- Drivers that relied on the old ref default of `update_period=None` change + behaviour if they migrate to a bare getter: they gain a connect-time read. + This is intended (see the three options above) but is the one migration step + that is not purely mechanical. + +## Questions resolved in review (#402, #412) + +1. **What replaces the `io=` object and the `ReadIO`/`WriteIO`/`ReadWriteIO` + hierarchy?** Plain `getter`/`setter` callables on the constructors. The IO + class hierarchy and its abstract-method enforcement are dropped entirely; + access mode is enforced by which parameters exist. +2. **Do we still need a runtime access-mode check?** Yes, in addition to the + static shape: a runtime check (e.g. at `post_initialise`) catches a missing + setter on a write-capable `Attr` for the dynamically-built `Any`-typed case. +3. **What is the public replacement for `fastcs-secop`'s + `_call_sync_setpoint_callbacks`?** A `setter` returning `T | Update[T]` *is* + the sanctioned setpoint echo — the returned value updates the readback and + the setpoint cache. +4. **Are there `CallbackReadIO`/`CallbackWriteIO` classes in core?** No. The + one-off callback case folds into `@attr` / `AttrR(getter=…)` + ([ADR 18](0018-attr-decorator-sugar.md)); the same spelling covers the + read-only and read/write cases. +5. **How is per-attribute IO metadata recovered from outside `getter`/`setter`?** + Through `attr.meta` and the attribute's own public members, replacing + `fastcs-catio`'s `attribute.io_ref` access. +6. **Is the setpoint echo a cross-transport "instantly visible" guarantee?** + (@Tom-Willemsen / @shihab-dls.) It is now. Caching `.setpoint` before running + the setter was originally an *attribute-cache* guarantee only, with the + remote-client view left transport-dependent: **PVA** posted the setpoint as + soon as it was written, whereas **CA** posted only *after* the update callback + completed, so a long-running setter delayed the CA-visible setpoint. The + follow-up this left open is closed by + [ADR 20](0020-transport-setpoint-mirroring.md): every transport now mirrors + the attribute's setpoint through `add_setpoint_callback()`, which fires + before the setter runs, so CA and PVA agree and the ordering is a property of + the attribute rather than of each transport. +7. **Should `poll_period` be a second constructor argument?** No — merged into + the getter as `Polled`/`NotPolled` wrappers, so a getter and its schedule are + one value and the same vocabulary works in the `@attr` decorator, where there + is no getter argument to pair it with. diff --git a/docs/explanations/decisions/0015-typed-commands.md b/docs/explanations/decisions/0015-typed-commands.md new file mode 100644 index 000000000..e59f9017d --- /dev/null +++ b/docs/explanations/decisions/0015-typed-commands.md @@ -0,0 +1,132 @@ +# 15. Typed Commands + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) + +## Status + +Proposed + +## Context + +FastCS `Command` (`src/fastcs/methods/command.py`) is void/void only: +`Method._validate` requires zero parameters and `None`/empty return type. +`UnboundCommand.bind` produces a `Command` wrapping a zero-arg, no-return +async callable. `Method.__init__` already captures the full +`inspect.Signature` of the wrapped function (`method.py:21`), but `Command`'s +own `_validate` throws that signature away by rejecting anything with +parameters. + +ophyd-async's equivalent, `Command[P, T]` (`ophyd_async/core/_command.py`), +carries a real parameter and return type, exposed via `CommandBackend.signature` +and `CommandBackend.execute(*args, **kwargs) -> T`. `TriggerableCommand = +Command[[], None]` is the void/void case, expressed as a special case of the +general one rather than the only case. + +This gap already shows in our downstream drivers rather than being +speculative: `fastcs-secop` builds command arguments and results dynamically +from SECoP's wire `datainfo` (`_controllers.py:102-110`) — a genuinely typed +(if dynamically-typed) command surface that FastCS's void/void `Command` +cannot represent today, forcing `fastcs-secop` to route command arguments +through attributes on a dedicated `SecopCommandController` instead of a +single typed call. + +## Decision + +Lift the zero-arg/no-return restriction in `Method`/`Command._validate`, and +introduce `Command[P, T]` generic over parameters and return type, keeping +the already-captured `inspect.Signature` as the public surface — +`ControllerAPI` exposes it directly, mirroring `CommandBackend.signature`. + +Transport capability is declared, not assumed uniform: + +- **Tango, REST, GraphQL, and the embedded ophyd-async connector** serve + typed commands fully — arguments and return value round-trip through + each protocol's native typed-call mechanism. +- **EPICS CA/PVA** stay void/void at the wire level (there is no PV + representation of "call with these typed arguments, get this typed + return" that doesn't already exist as separate attributes). They **skip + typed commands with a warning** at start-up rather than failing to serve + the controller at all. A command the user explicitly declares as typed + *and* forces to be served over an EPICS-only transport is a hard error — + matching the existing ophyd-async connector's behaviour, which errors + rather than silently drops when a `Device` requires a capability its + connector cannot provide. + +```python +class Ramp(Controller): + move_to: Command[[float], None] # typed: not served over CA/PVA + stop: Command[[], None] # void/void: served everywhere +``` + +**Argument and return typing are independent** (not all-or-nothing): + +- *Args*: `[]` (none) · `[DT1, DT2, …]` (positional, known types — validated). +- *Returns*: `None` · `DT` (a single typed value). + +There is **no partial `Command[Any, Any]`** — a statically-declared `Command` +always has its parameter and return types fully known. The alternative is not +a half-known command but a fully-dynamic controller: a driver that knows +*nothing* statically (`fastcs-secop`, discovering everything from an +over-the-wire `describe`) does not annotate a `Command` at all — it builds the +whole structure, attributes and commands alike, at runtime. So `P`/`T` are +either **completely known** (static declaration) or the **whole structure is +unknown** (runtime construction); there is no in-between case where you know +something is a command but not its signature. This is the same "hint vs. +no-hint" split as [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) +for attributes — with no partial hint. + +Following the [ADR 17](0017-naming-pass.md) `DataType` drop, command +arguments/returns use plain python types + `*Meta` exactly as attributes do +(no metadata ⇒ "use the python type"), and the serialisation machinery is +**shared** with `Attribute`, not duplicated. **Keyword-argument** commands +need a `TYPE_CHECKING` stub trick and are prototyped separately in the spike +[#403](https://github.com/DiamondLightSource/fastcs/issues/403), not here. + +## Consequences + +- `Command.__call__` gains real `*args`/`**kwargs` forwarding instead of a + bare `await self.fn()`; `UnboundCommand.bind` needs the same treatment. +- `ControllerAPI` (or its per-command entries) needs to expose the + signature to transports, so each transport can decide serve-fully / + serve-with-warning / hard-error per decision above. +- EPICS transports (`transports/epics/ca`, `transports/epics/pva`) need a + capability check at controller-API-build time, producing a startup-time + warning log rather than a runtime failure per typed-command call. +- `fastcs-secop` (and any introspection-driven driver) does **not** get a + `Command[Any, Any]`. Since SECoP's `describe` reveals the entire structure + only at connect time — there is no static declaration of *anything*, let + alone a command of unknown signature — such drivers build their commands + programmatically at runtime, each carrying a concrete signature derived from + the wire `datainfo`. Static `Command[P, T]` is for statically-declared + controllers; fully-dynamic drivers construct commands (or keep the existing + `SecopCommandController` explode-to-PVs workaround) at runtime instead. +- Command args/return values validate through the **same** python-type + + `*Meta` mechanism as attributes (the `DataType` family is removed, ADR 17) — + one shared validation/serialisation path, no command-specific duplicate. + +## Questions resolved in review (#402) + +1. **Do commands need their own type/serialisation mechanism?** No — they share + the attribute path. With `DataType` dropped ([ADR 17](0017-naming-pass.md)), + command args/returns use python types + `*Meta` like attributes, and + complex-type serialisation (arrays, `Enum`, `Table`) is shared with + `Attribute`. +2. **Are args and returns typed all-or-nothing?** No — independently: args `[]` + / `[DT…]`; returns `None` / `DT` (see Decision). Each is fully known — there + is no `Any` middle case. +3. **Is there a partial `Command[Any, Any]`?** No. @Tom-Willemsen confirmed on + [#402](https://github.com/DiamondLightSource/fastcs/pull/402#discussion_r3621453680) + that SECoP devices are discovered entirely from an over-the-wire `describe`: + you never statically know something is a command but not its signature — you + either know the full `Command[P, T]` or you know nothing at all and build the + whole controller at runtime. `P`/`T` are therefore completely known or the + whole structure is unknown, with no in-between. +4. **When does the EPICS skip-with-warning fire?** At IOC startup — post + controller construction, when the fully populated controllers are handed to + the transports to serve. +5. **What about keyword-argument commands?** Deferred to spike + [#403](https://github.com/DiamondLightSource/fastcs/issues/403) + (interactive/Opus; needs a `TYPE_CHECKING` stub). Out of scope for core + typed-command work. diff --git a/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md b/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md new file mode 100644 index 000000000..4f67b0528 --- /dev/null +++ b/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md @@ -0,0 +1,120 @@ +# 16. AttrW Setpoint Cache, Native Timestamps, and ControllerRunner + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 14](0014-attribute-io-rw-rework.md) + +## Status + +Proposed + +## Context + +Three related gaps block a clean embedded ophyd-async connector +(see [ADR 19](0019-embedded-ophyd-async-connector.md)) and are useful to all +transports independently of embedding: + +1. **No cached setpoint.** The old `AttrW.put` (`src/fastcs/attributes/attr_w.py`) + applied a setpoint via `_on_put_callback` but did not retain it anywhere + queryable. ophyd-async's `SignalBackend.get_setpoint()` — needed for + `locate()` — has no FastCS equivalent to read from. +2. **No FastCS-native timestamps.** The old `AttrR.update` (`attr_r.py`) stamped + nothing; individual transports each did their own thing (EPICS records + get a timestamp from the record subsystem, Tango pushes are unstamped). + An embedded connector currently has no choice but to stamp receive-time + only, which is a real information loss versus what the underlying device + protocol may already provide (Tango event timestamps, EPICS record + timestamps at source). +3. **No documented, extracted runtime.** `FastCS.serve` (`control_system.py`) + inlines the full controller lifecycle — `initialise()` → + `post_initialise()` → `create_api_and_tasks()` → `connect()` → initial + coroutines → scan tasks — as private logic inside the `serve` coroutine. + There is no standalone object an embedding connector can start/stop + without also pulling in `FastCS`'s transport-serving and interactive-shell + concerns. + +Decision 13 of #388 requires this lifecycle, plus `ControllerAPI` and the +attribute/command runtime methods, to be formalised as fastcs-core's single +documented "stable interface" that the ophyd-async connector is restricted +to using — no reaching into `BaseController` internals. + +## Decision + +**Setpoint cache.** `AttrW`/`AttrRW` retain the last-applied setpoint, exposed +via the sync `.setpoint` property from [ADR 14](0014-attribute-io-rw-rework.md)'s +runtime surface (the FastCS analogue of ophyd-async's +`SignalBackend.get_setpoint()`). `set(value)` caches it immediately — before +the setter runs and independent of whether the setter succeeds — so `.setpoint` +is a "what did we last ask for" query, distinct from `.readback` ("what did we +last read back"). This is available to all transports, not just the embedded +connector. + +**Native timestamps (+ severity).** A value entering an `AttrR`/`AttrRW` may +carry a timestamp and severity by arriving as an `Update[T]` +([ADR 14](0014-attribute-io-rw-rework.md)) — from a getter's return, a +value-returning setter, or a `@scan`/subscription `update()` push — defaulting +to framework receive-time when the timestamp is `None`. The timestamp/severity +pair **follows bluesky's `Reading` shape but shares no code** with it, and +severity is a **FastCS enum using the same strings as EPICS** alarm severities. +This is FastCS-native, not EPICS-specific — Tango event pushes and other IO can +supply a device-side timestamp through the same `Update[T]` path a getter +already uses. The embedded connector stamps receive-time only as an interim +measure until this lands, per decision 10 of #388 — this is 1.0 scope, not a +follow-up. + +**ControllerRunner.** Extract the controller lifecycle currently inlined in +`FastCS.serve` into a standalone `ControllerRunner` (or equivalent +`Controller.serve()`/`Controller.stop()` API), independent of the +transport-serving and interactive-shell logic that stays in `FastCS`/ +`control_system.py`. `FastCS.serve` becomes a thin caller of +`ControllerRunner` plus transport wiring. The runner owns: + +- Running `initialise()`/`post_initialise()`/`create_api_and_tasks()` once. +- Running `connect()` and the initial coroutines. +- Starting/stopping the periodic scan tasks. +- The **whole lifecycle including reconnect** — calling `Controller.reconnect()` + on scan-task failure; reconnect is owned by the runner, not left + controller-specific. + +The runner is a class with `start()`/`stop()` (ophyd-async calls `start`/`stop`; +an `async with` context manager is added only if it also suits the `FastCS()` +case). **Idempotency is the caller's responsibility**, not the runner's — the +embedded connector's `connect_real` may run more than once across reconnects +(see [ADR 19](0019-embedded-ophyd-async-connector.md)). + +This, together with `ControllerAPI` and the attribute/command runtime surface +from [ADR 14](0014-attribute-io-rw-rework.md) (`.readback`/`poll()` + +update-callback registration, `set()` + the `.setpoint` cache, +`attr.meta`/`access_mode`/`description`/`group`), becomes the documented stable +surface referenced by decision 13 of #388. + +## Consequences + +- `FastCS.serve` shrinks to transport orchestration; the controller + lifecycle it currently inlines becomes independently testable and + reusable without instantiating a `FastCS` object or any `Transport`. +- Every getter/setter *may* return an `Update[T]` to supply a + timestamp/severity, but a bare value is unaffected — it defaults to + framework receive-time, severity unset. +- Transports gain access to a real setpoint distinct from the readback + value; whether EPICS/Tango/REST/GraphQL surface this as new fields is + transport-specific follow-up work, not part of this ADR. +- The embedded ophyd-async connector becomes buildable against a documented, + narrow surface instead of `BaseController` internals — see + [ADR 19](0019-embedded-ophyd-async-connector.md). + +## Questions resolved in review (#402) + +1. **How is the cached setpoint exposed?** Via the `.setpoint` property from + [ADR 14](0014-attribute-io-rw-rework.md)'s runtime surface (the FastCS + analogue of `SignalBackend.get_setpoint()`), cached by `set()` before the + setter runs. +2. **What shape do timestamp/severity take?** They follow bluesky's `Reading` + shape but **share no code**; severity is a **FastCS enum using the same + strings as EPICS**, carried on `Update[T]`. +3. **What is the runner's shape?** A class with `start()`/`stop()` (context + manager only if it also suits `FastCS()`); **idempotency is the caller's + responsibility**. +4. **Who owns reconnect?** The runner owns the whole lifecycle, including + reconnect. diff --git a/docs/explanations/decisions/0017-naming-pass.md b/docs/explanations/decisions/0017-naming-pass.md new file mode 100644 index 000000000..85af66a07 --- /dev/null +++ b/docs/explanations/decisions/0017-naming-pass.md @@ -0,0 +1,109 @@ +# 17. Naming Pass: precision, Limits Alignment, Array1D/Table Hints + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 14](0014-attribute-io-rw-rework.md), [ADR 15](0015-typed-commands.md) + +## Status + +Proposed + +## Context + +FastCS and ophyd-async independently arrived at similar concepts with +different names, which is exactly the "false friend" risk #388 opens with: +a developer moving between the two projects can be misled into assuming a +name means the same thing, or reach for a name that does not exist. + +Concretely, `_Numeric` (`src/fastcs/datatypes/_numeric.py`) and `Float` +(`src/fastcs/datatypes/float.py`) use `prec`/`min`/`max`/`min_alarm`/ +`max_alarm`; ophyd-async and the wider bluesky event-model use +`precision` and a `Limits` structure (`Limits(low, high)` per category, e.g. +control/display/alarm/warning) rather than five flat fields. FastCS's +`Waveform(array_dtype, shape)` and `Table` datatypes have no hint-level +spelling analogous to ophyd-async's `Array1D[np.int32]` (a `numpy.ndarray` +subscripted for shape) and `Table` (pydantic-based) hint syntax — under +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md), bare +hints are now load-bearing (they are what `ControllerFiller` scans), so +having ophyd-async-compatible hint spellings for array/table attributes +becomes more valuable than it was when hints were validation-only. + +This pass now lives on python types + `*Meta` typed dicts, not on `DataType` +classes: the `DataType` family is dropped ([ADR 14](0014-attribute-io-rw-rework.md), +[ADR 15](0015-typed-commands.md)), so these renames fold into the per-attribute +IO rework (issue #392) rather than landing as a separate late PR. The concrete +`*Meta` mechanism (per-datatype `TypedDict`s, the superset `Meta` for extras, +`attr.meta` storage, the `Unpack` overloads) is specified in +[ADR 14](0014-attribute-io-rw-rework.md); the module home for these public +names is decided in #406. + +Since this is a pre-1.0 breaking-change window (per #388's framing — +"while breaking pre-1.0"), this is the point to make these renames, not +after 1.0 when they become a deprecation cycle. + +## Decision + +1. **`prec` → `precision`.** Rename across the numeric metadata (`FloatMeta`, + transports, docs, snippets) wherever `prec` appears. `precision` stays an + `int` (decimal places). No behaviour change. + +2. **Limits alignment — nested, not flat.** Replace the flat + `min`/`max`/`min_alarm`/`max_alarm` fields with a nested `Limits` structure + aligned to the bluesky event-model, so alarm/control/display limits read the + same way in FastCS and ophyd-async docs. **All four categories** — control, + display, alarm, warning — are present and **all optional**, with inheritance: + + - supply none ⇒ all unbounded; + - Display but not Control ⇒ Control inherits Display (for a writeable attr); + - Alarm but not Warning ⇒ Warning inherits Alarm; + - both Alarm and Warning ⇒ assert Warning ⊆ Alarm; + - otherwise unspecified ⇒ unbounded. + +3. **`Array1D`/`Table` hint spellings, which are also the runtime structure.** + Adopt `Array1D[np.int32]` and `Table` as the FastCS *hint* spellings a + `ControllerFiller`-scanned class body uses. With `DataType` dropped, these + are **both** the hint and the runtime structure passed around as the + datatype — there is no separate `Waveform`/table `DataType` object to map to. + Procedural construction passes the same types plus `*Meta` (e.g. + `AttrRW(Array1D[np.int32], shape=(4,), getter=...)`), and shape/array + metadata rides on `Array1DMeta` exactly as `precision`/`units` ride on + `FloatMeta`. + +This is explicitly the smallest naming-pass scope agreed in #388 for 1.0. A +`Prec`/`Units`/`Shape` `Annotated` extras vocabulary (letting a hint carry +precision/units/shape without a spec object) is called out in #388 as a +**post-1.0** option enabled by, but not required by, the +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md) extras +mechanism — not part of this ADR. + +## Consequences + +- Every driver using `Float(prec=...)`, `.min`/`.max`/`.min_alarm`/ + `.max_alarm` needs a rename to `precision` and the nested `Limits` + structure. This is a wide diff across all downstream repos (`fastcs-eiger`, + `fastcs-catio`, `fastcs-secop`, `fastcs-PandABlocks` all use numeric + limits somewhere); the flat→nested Limits change is structural, not purely a + rename. +- Transports serving `precision`/limits metadata (EPICS record fields, + Tango attribute properties, REST/GraphQL schema) read these from `attr.meta` + ([ADR 14](0014-attribute-io-rw-rework.md)) and need their field-name mapping + updated to the renamed / nested fields. +- `Array1D`/`Table` become the single array/table representation for both + hinted and procedural attributes, so there is no hint-vs-runtime mapping + layer to keep in sync. + +## Questions resolved in review (#402) + +1. **Flat or nested limits?** Nested — a `Limits` structure, not four flat + fields. +2. **Which limit categories, and how do they combine?** All four + (control/display/alarm/warning), all optional, with the inheritance rules in + Decision point 2 (Control inherits Display, Warning inherits Alarm, assert + Warning ⊆ Alarm, otherwise unbounded). +3. **Is `precision` an int or a float?** An `int` (decimal places). +4. **Do `Array1D`/`Table` map onto a separate runtime `DataType`?** No — with + `DataType` dropped they *are* both the hint and the runtime structure; there + is no `Waveform` object to map to. +5. **Where does this land?** It folds naturally into the per-attribute IO / + `DataType`-drop PR (#392) — the implementer's choice, not a separate late PR. diff --git a/docs/explanations/decisions/0018-attr-decorator-sugar.md b/docs/explanations/decisions/0018-attr-decorator-sugar.md new file mode 100644 index 000000000..196775cbf --- /dev/null +++ b/docs/explanations/decisions/0018-attr-decorator-sugar.md @@ -0,0 +1,156 @@ +# 18. Attr-from-Method Decorator Sugar (`@attr` + `@x.setter`) + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md), +[ADR 14](0014-attribute-io-rw-rework.md) + +## Status + +Proposed + +## Context + +#388 §7.5 makes the case that FastCS must also sell as a way to write Tango +Device Servers, competing directly with PyTango on the trivial case, not +just on the advanced multi-transport pitch. PyTango's hello-world is one +decorated getter: + +```python +@attribute +def current(self) -> float: + return 2.5 +``` + +Under the [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) +harsh declarative/procedural split (bare hints only in the class body; all +IO wiring procedural), the equivalent trivial case would regress to a method +plus explicit `AttrR(getter=...)` wiring in `__init__` — strictly more +ceremony than PyTango for the simple case that most new users hit first. This +is exactly the kind of "false friend" gap #388 warns about: a PyTango user +evaluating FastCS should not find the *simple* case harder than what they're +moving away from. + +FastCS already has precedent for binding class-body decorated methods to +per-instance callables without any deepcopy hazard: `@command`/`@scan` +(`src/fastcs/methods/command.py`, `scan.py`) use `UnboundCommand`/ +`UnboundScan`, which wrap an unbound function and `.bind(controller)` a +fresh `Command`/`Scan` object per instance at construction time. Because +these are fresh objects constructed per-instance (not deepcopied +prototypes), they carry none of the aliasing hazard that class-scope +`Attribute` *instances* had — which is why +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md) +removes the latter but keeps `@command`/`@scan`. + +## Decision + +Add `@attr` as pure sugar over `AttrR`/`AttrRW` plus generated getter/setter +callables ([ADR 14](0014-attribute-io-rw-rework.md)), built on the same +`Unbound*`-style bind machinery as `@command`/`@scan` — fresh objects per +instance, no prototype/deepcopy hazard, consistent with keeping this a +class-body citizen under +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md). The +decorator mirrors `@property`: `@attr` on the getter, `@voltage.setter` on the +writer. + +```python +class PowerSupply(Controller): + @attr(Polled(0.5), units="V") # datatype inferred from -> float + async def voltage(self) -> float: + """Output voltage.""" + return await self._conn.query("V?") + + @voltage.setter + async def voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") +``` + +- The datatype is inferred from the return type annotation of the getter + (`-> float` → a `float` attribute), matching how the datatype is inferred + from a getter's annotation on the procedural + `AttrR(getter=…)`/`AttrRW(getter=…, setter=…)` form + ([ADR 14](0014-attribute-io-rw-rework.md)), rather than requiring a `dtype=` + keyword the way PyTango does — decision 14 of #388 explicitly calls this out + as *better* than PyTango's `dtype=` kwarg since it's one real annotation, + checked statically. +- `@attr` comes in two forms: bare `@attr` and parameterised + `@attr(Polled(0.5), precision=3, units="V")`. The keyword arguments map onto + the same `*Meta` fields (typed with `Unpack[…Meta]`, validated against the + getter's return type). The optional leading positional is a **schedule** - + the same `Polled`/`NotPolled` objects the procedural form wraps its getter in + ([ADR 14](0014-attribute-io-rw-rework.md), amendment 2026-08-03) - so the two + spellings share one vocabulary rather than the decorator taking a + `poll_period=` kwarg the constructor no longer has. Sugar over that + mechanism, not a parallel one. +- **Bare `@attr` means the same as a bare `getter=`**: read once, when the + controller connects. This symmetry is why the constructor keeps a default + instead of demanding a wrapper - a bare decorator has to resolve to some + schedule, so both sides default to the same safe one: + + | Schedule | Procedural | Declarative | + |---|---|---| + | Once, at connect | `AttrR(t, getter=g)` | `@attr(units="V")` | + | Every 0.5s | `AttrR(t, getter=Polled(g, period=0.5))` | `@attr(Polled(0.5), units="V")` | + | Never; `poll()` only | `AttrR(t, getter=NotPolled(g))` | `@attr(NotPolled(), units="V")` | +- `@attr`'s `.setter` decorator mirrors `@property`/`@x.setter`, giving the + read+write pair a single logical name (`voltage`) with two decorated methods. + There is **no dedicated write-only decorator** — a paired-getter-less `AttrW` + is rare, so it is written longhand as `AttrW(setter=…)`. +- The getter's docstring becomes the attribute's `description`, as + `@command`/`@scan` already do. +- `@attr` supports the [ADR 17](0017-naming-pass.md) `Array1D`/`Table` hint + spellings as the getter's return annotation. +- There is **no** free-function `attr()` factory: the procedural spelling is + `AttrR(getter=…)` / `AttrRW(getter=…, setter=…)` directly. `@attr` degrades + gracefully into that procedural form for protocol families with more complex + needs, and into + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s filler + for introspection-driven attributes — `@attr` is explicitly the *simple* + case, not a replacement for either. +- Refines the class-body rule stated in + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) to: + *class body = declarations + decorated behaviour; instance scope = + construction with data* — already true today via `@command`/`@scan`, now + extended to attributes. +- Docs gain a "FastCS for PyTango users" page pairing this decorator with the + equivalent PyTango snippet, landing alongside this PR per #388 §8 item 5b. + +Interaction with the filler: an `@attr`-decorated attribute is already defined, +so [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s +`ControllerFiller` treats it as filled and does not shadow it; a clash between +an introspected name and a decorated name raises. + +## Consequences + +- New driver code for the common "one attribute, one device call" case gets + noticeably shorter — closing the gap #388 §7.5 identifies against PyTango. +- `@attr` and the procedural `AttrR(getter=…)` / `AttrRW(getter=…, setter=…)` + form are two spellings over one implementation — the generated getter/setter + from [ADR 14](0014-attribute-io-rw-rework.md), with no separate callback-IO + classes. +- There are three ways to declare an attribute (bare hint + filler; explicit + `AttrRW(getter=…, setter=…)`; `@attr` sugar) — the docs need to be clear + about when to reach for which, so this doesn't become three equally-weighted + options with no guidance, undermining the "harsh split" clarity + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) is + trying to establish. +- Per #388 §8 item 5b, this sits on PR 1 (the per-attribute IO rework) — small + and independent of the `ControllerFiller` work — so it can land early and not + block on [ADR 13](0013-declarative-procedural-split-and-controller-filler.md). + +## Questions resolved in review (#402) + +1. **What is the decorator spelling?** `@attr` + `@x.setter` + (property-mirroring), not `@attr_r`/`@attr_rw`/`.send`. No dedicated + write-only decorator — `AttrW` alone is rare, written longhand. +2. **How is datatype/limits metadata passed?** Via decorator kwargs, typed with + `Unpack[…Meta]` (`precision`, `units`, limits — the + [ADR 14](0014-attribute-io-rw-rework.md)/[ADR 17](0017-naming-pass.md) + `*Meta` fields), validated against the getter's return type. +3. **Does it support the `Array1D`/`Table` hints?** Yes, as the getter's return + annotation. +4. **How does the filler treat a decorated attr?** As already defined — not + shadowed; a clash between an introspected name and a decorated name raises. +5. **Does the getter's docstring become the `description`?** Yes, as + `@command`/`@scan` already do. diff --git a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md new file mode 100644 index 000000000..b61c43704 --- /dev/null +++ b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md @@ -0,0 +1,179 @@ +# 19. Embedded ophyd-async Connector + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md), +[ADR 14](0014-attribute-io-rw-rework.md), [ADR 15](0015-typed-commands.md), +[ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) + +## Status + +Proposed + +## Context + +ophyd-async already bridges to FastCS over the network: +`ophyd_async.fastcs.core.fastcs_connector(uri)` is a `PviDeviceConnector` +talking PVA+PVI. #388 proposes an **in-process** embedding as well — running +a FastCS `Controller` directly inside a bluesky/ophyd-async process, with no +network hop, for cases like running a `TemperatureController` straight from +a bluesky plan. + +Researching ophyd-async's `DeviceFiller` +(`ophyd_async/core/_device_filler.py`) as the direct structural reference +for [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s +`ControllerFiller` surfaced the exact shape this connector needs to take: + +- `DeviceConnector.create_children_from_annotations` builds a `DeviceFiller` + once (memoised via `hasattr(self, "filler")`), then either fills + immediately or defers to `connect_real`. +- `connect_real` is where PVI and Tango connectors both actually introspect + and fill children — there is no ophyd-async precedent for "fill + everything at construction time" in a connect-time-introspecting + connector; embedding should follow the same connect-time pattern rather + than trying to fill eagerly. +- `SignalBackend`'s methods (`get_value`, `get_setpoint`, `set_callback`, + `put`, `get_datakey`) are the exact surface a `FastCSSignalBackend` needs + to implement in terms of FastCS's `.readback`/`.set()`/setpoint cache/native + timestamps (from [ADR 14](0014-attribute-io-rw-rework.md) and + [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)). +- `CommandBackend.execute`/`.signature` is the equivalent surface for typed + commands (from [ADR 15](0015-typed-commands.md)). + +This connector is explicitly the motivating consumer for +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md), +[ADR 15](0015-typed-commands.md), and +[ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) — it is +what forces those three to define a genuinely stable, documented surface +rather than an implicit one, since it lives in a different package +(ophyd-async) and cannot reach into FastCS internals the way FastCS's own +transports currently can. + +## Decision + +Per decision 6 of #388: no shared package. `FastCSDeviceConnector` lives +entirely on the ophyd-async side, behind an `ophyd-async[fastcs-embed]` +extra, importing only the stable FastCS surface formalised by +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md) +(`ControllerFiller`) and [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) +(`ControllerAPI` tree, `ControllerRunner`, and the attribute/command runtime +methods). Convergence is by convention (the two projects agreeing on shape), +not by shared code. + +```python +from ophyd_async.fastcs import embedded_fastcs_connector + +class TempStage(Device): + ramp_rate: SignalRW[float] + power: SignalR[float] + cancel_all: TriggerableCommand + ramps: DeviceVector[TempRamp] + +stage = TempStage(connector=embedded_fastcs_connector(TemperatureController(settings))) +await stage.connect() # runs controller lifecycle in-process +``` + +Mechanics, directly mirroring `PviDeviceConnector`/`TangoDeviceConnector`: + +- `create_children_from_annotations`: builds a `DeviceFiller` with + `FastCSSignalBackend`/`FastCSCommandBackend` factories, `filled=False` — + same lazy pattern as the network connectors. +- `connect_real` (top level): starts the `ControllerRunner` — `initialise()`, + `post_initialise()`, `create_api_and_tasks()`, `Controller.connect()`, + initial coroutines, scan tasks scheduled on the *running* (bluesky) event + loop — then walks the `ControllerAPI` tree filling children via the + `DeviceFiller`, `check_filled()`, `set_name()`. Idempotent across + reconnects, per [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)'s + `ControllerRunner` requirement. +- `connect_mock` never touches the controller, so mock-mode ophyd-async + usage stays free (no FastCS controller/connection is instantiated at all). +- Lifecycle (decision 8 of #388): the connector owns the runner; shutdown + via an `atexit` hook plus an explicit `await connector.shutdown()`, which + cancels scan tasks and calls `Controller.disconnect()`. Reconnect is + `Device.connect(force_reconnect=True)`; there is no `Device.disconnect()` + proposal, and the only disconnect we want is `atexit`. +- Errors: FastCS gains a `ConnectionFailedError` (raised when the device + doesn't respond); the connector converts it to `NotConnectedError` and keeps + retrying to connect in the background. All other errors surface unconverted. +- Embedded + transports simultaneously (decision 9 of #388, e.g. a CA GUI + running next to a bluesky plan) is explicitly out of scope for the first + cut, but the `ControllerRunner` is designed so a transport list can be + attached later without redesigning it. + +Backend mappings (from #388 §5, grounded against the researched +`DeviceFiller`/`SignalBackend` surface): + +| ophyd-async | FastCS | +|---|---| +| `SignalBackend.get_value` | `AttrR.readback` | +| `SignalBackend.set_callback` | update-callback registration (`always=True`); stamped per [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | +| `SignalBackend.put` | `AttrW.set(value)` | +| `SignalBackend.get_setpoint` | `AttrW.setpoint` cache, [ADR 14](0014-attribute-io-rw-rework.md)/[ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | +| `SignalBackend.get_datakey` | `attr.meta` (units, precision, limits) + python-type/enum choices → `SignalMetadata` + `make_datakey` | +| `CommandBackend.execute`/`.signature` | `Command.__call__` / captured `Signature`, [ADR 15](0015-typed-commands.md) | +| `SignalBackend.source` | e.g. `fastcs://.` | +| child `Device` / `DeviceVector` | sub-`Controller` / `ControllerVector` | +| (not exposed) | `@scan` methods — purely-internal periodic coroutines, bound to no `Attr`, not surfaced to ophyd-async (@shihab-dls, #402) | + +Datatype mapping: `int`/`float`/`bool`/`str` map straight across; the +`Array1D[dtype]` hint/runtime type (per [ADR 17](0017-naming-pass.md)) → +ophyd-async `Array1D[dtype]`; an enum class → the enum class itself. Two cases +needed a decision: + +- **Enums:** un-hinted enum classes introspect at runtime and drop to a string + datatype retaining the choices as metadata; hint-typed enums require the + author to duplicate as a `StrictEnum`/`SubsetEnum`/`SupersetEnum` (as they + would for remote FastCS) for now — revisit once there are use cases. +- **`Table`:** a real bidirectional converter **is in scope for the first + cut**, used as the opportunity to bring the FastCS and ophyd-async `Table` + implementations closer together. + +## Consequences + +- The stable FastCS interface promised in + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)/ + [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) gets + its first external, cross-repo consumer — any accidental internal + dependency this connector picks up is a signal that surface isn't + actually stable yet. +- This is entirely an ophyd-async-side deliverable (§8 items 6-8); + fastcs-core work is a dependency, not part of this repo's PRs. +- Per #388 coordination note: items 1-4 of the §8 work plan land in FastCS + before item 6 is mergeable; prototyping item 6 against a FastCS branch to + validate the stable interface *before* freezing it is the recommended + order, i.e. this connector should be prototyped against the `refactor` + branch here as the other ADRs' implementations land, not written blind + against a spec. +- `fastcs-demo`'s temperature controller simulation + (`fastcs.demo.simulation`) is the existing sim device ophyd-async tests + against — no new simulated device is needed for the first cut. +- Dropping `Device.disconnect()` means #388 §8 item 8 / issue #401 is + rewritten accordingly (reconnect via `force_reconnect=True`, disconnect via + `atexit` only). + +## Questions resolved in review (#402) + +1. **How are enums mapped?** Un-hinted → runtime-introspect, drop to string + keeping choices as metadata; hinted → require + `StrictEnum`/`SubsetEnum`/`SupersetEnum` duplication for now, revisit with + use cases. +2. **Is `Table` supported in the first cut?** Yes — a bidirectional converter, + used to converge the two `Table` implementations. +3. **How are connection errors handled?** FastCS gains a + `ConnectionFailedError`; the connector converts it to `NotConnectedError` and + keeps retrying to connect in the background. All other errors surface + unconverted. +4. **Is there a `Device.disconnect()`?** No — reconnect is + `Device.connect(force_reconnect=True)`; the only disconnect is `atexit`. +5. **Where does `@scan`-derived state that isn't a `Signal` go?** (@shihab-dls.) + Nowhere extra is needed. A `@scan`-decorated method is a **purely internal** + coroutine run periodically; it is *not* bound to an `Attr` and produces **no** + `Signal`. All exposed state already lives in `Attr` instances: a getter-based + `AttrR` schedules its getter as a scan-style task *and* is bound to the Attr + (→ `Signal`), and a soft `AttrR` fed by `@scan` via `update()` is likewise + the exposed Signal — so `@scan` surfaces nothing extra. A `@command` method + **is** different: it creates an `AttrW` and **is** exposed (the + `CommandBackend` row above). Both `@scan` and getter/update coroutines are + collected onto the running loop in `create_api_and_tasks()`; the connector + schedules `@scan` coroutines as internal tasks but never maps them to Signals. diff --git a/docs/explanations/decisions/0020-transport-setpoint-mirroring.md b/docs/explanations/decisions/0020-transport-setpoint-mirroring.md new file mode 100644 index 000000000..5d83520f3 --- /dev/null +++ b/docs/explanations/decisions/0020-transport-setpoint-mirroring.md @@ -0,0 +1,69 @@ +# 20. Transports mirror the attribute setpoint rather than tracking their own + +Date: 2026-08-03 + +## Status + +Accepted + +## Context + +An `AttrRW` has two values a transport must present: the readback (what the device +reports) and the setpoint (what was last asked of it). Readbacks were already +published by callback - the attribute calls back on every change and each transport +posts it - but setpoints were not. + +Instead, each transport maintained its own setpoint display and updated it directly +in its write path, on the assumption that the only thing that could change a +setpoint was a write arriving through that same transport. That assumption is wrong +as soon as there is more than one transport, or a device that reports its own +setpoint. + +It also left a visible gap at startup. A setpoint display starts at the datatype's +default, which is usually not the device's actual value, so each transport grew a +one-shot "seeding" hack: subscribe to the *readback* callback, and the first time a +value arrives, copy it into the setpoint display and unsubscribe. Two transports had +near-identical copies of this, and it only worked for `AttrRW` (a pure `AttrW` has no +readback to seed from). + +The two EPICS transports had also drifted apart on ordering. PVA posted the setpoint +as soon as the put arrived, before the setter ran; CA posted it only after the update +callback completed, so a slow setter left the CA setpoint stale for the duration of +the write. + +## Decision + +The attribute owns the setpoint, and transports mirror it. + +- `AttrW` gains `add_setpoint_callback()`, the setpoint-side counterpart of + `AttrR.add_readback_callback()` (renamed from `add_on_update_callback()` for the + symmetry). Every transport registers one and posts whatever it is given. +- `AttrW.update_setpoint()` caches a setpoint and fires those callbacks. `set()` + calls it before running the setter, so the requested value is visible immediately; + a value returned by the setter goes through it again, so a clamped or rejected + value replaces it. +- A getter or setter can also drive the setpoint by returning + `Update(readback=..., setpoint=...)` - the mechanism for a device that reports its + own setpoint. `setpoint=None` (the default) leaves the cached setpoint alone. +- Seeding is gone. An `AttrRW` starts with no known setpoint, and the first readback + to arrive - from a poll, a scan, or anything else calling `update()` - establishes + it. Subsequent readbacks do not, so a readback that disagrees with the setpoint + does not silently rewrite what the user asked for. + +Transports must not update their own setpoint display directly in their write path. + +## Consequences + +Every transport shows the same setpoint, whichever transport was written through, and +CA and PVA now agree on when it appears: at the start of the write, before the setter +runs. That is the behaviour PVA already had, and it is the one that gives GUIs +immediate feedback. + +Attribution is lost at the transport layer - a client cannot tell from the setpoint +alone which transport originated a write. This is deliberate: consistency between +transports is worth more than attribution, and attribution is recoverable from the +logs, which record the originating transport for every `set()`. + +The one-shot seeding blocks in the CA and PVA transports are deleted, along with the +`isinstance(attribute, AttrR)` checks that guarded them, since the mechanism now works +for a pure `AttrW` too. diff --git a/docs/explanations/stable-interface.md b/docs/explanations/stable-interface.md new file mode 100644 index 000000000..52f9c4b21 --- /dev/null +++ b/docs/explanations/stable-interface.md @@ -0,0 +1,112 @@ +# The Stable Interface + +Most of FastCS is free to change while it is pre-1.0. A narrow part of it is +not: the surface an *embedder* uses — code that runs FastCS controllers inside +another framework rather than serving them over a transport, such as the +ophyd-async connector. That surface is listed here, and an embedder should use +nothing outside it. In particular, nothing should reach into `BaseController`. + +## Running controllers: `ControllerRunner` + +`ControllerRunner` owns the controller lifecycle and nothing else — no +transports, no interactive shell. `FastCS` is a caller of it. + +```python +from fastcs.controllers import ControllerRunner + +runner = ControllerRunner(controller) +apis = await runner.setup() # initialise, and build the ControllerAPIs +await runner.start() # connect, run initial tasks, start scanning +... +await runner.stop() # stop the tasks, disconnect +``` + +- **`setup()`** runs `initialise()` and `post_initialise()` on each controller + and builds their `ControllerAPI`s. It exists as a separate step because + anything serving the controllers has to register its callbacks *before* the + first values are read, or it misses them. +- **`start()`** connects the controllers, runs the initial (`ONCE`) tasks, and + starts the periodic ones. It runs `setup()` first if you have not, so an + embedder that does not need the APIs in between can just call `start()`. +- **`stop()`** cancels the tasks and disconnects. + +**Idempotency is the caller's responsibility.** Starting a running runner, or +stopping a stopped one, is not defined — an embedder whose own connect may run +more than once has to keep track itself. + +The runner also owns **reconnect**. A scan task whose callback raises marks its +controller disconnected and pauses rather than dying; the runner notices and +calls `Controller.reconnect()` until it comes back. This is deliberately not +left to each controller, so every controller recovers the same way. + +## Reading the structure: `ControllerAPI` + +`ControllerAPI` is the read-only view of a controller: + +- `attributes` — the `Attribute`s, by name +- `command_methods` — the `Command`s, by name +- `scan_methods` — the `Scan`s, by name +- `sub_apis` — child `ControllerAPI`s, by name +- `path` and `description` +- `walk_api()` — this API and every descendant + +## Reading and writing values: the attribute surface + +For an `AttrR` (and so an `AttrRW`): + +- `readback` — the last known value +- `timestamp` — when that value was obtained, as a unix timestamp: the time the + source reported if it reported one, otherwise the time the update arrived +- `severity` — how wrong that value is, as a `Severity` +- `await poll()` — read a fresh value from the getter, cache it, return it +- `await update(value)` — push a value into the cache without any IO. Accepts a + bare value or an `Update`, which may carry a timestamp and severity +- `add_readback_callback(cb)` — be told when the readback changes + +For an `AttrW` (and so an `AttrRW`): + +- `setpoint` — the last value asked for. Cached by `set()` *before* the setter + runs and regardless of whether it succeeds, so it answers "what did we last + ask for", distinct from `readback`'s "what did we last read" +- `await set(value)` — cache the setpoint and apply it through the setter +- `add_setpoint_callback(cb)` — be told when the setpoint changes + +For any attribute: `dtype`, `access_mode`, `description`, `group`, and the +metadata it carries. + +## Calling actions: the command surface + +- `await command()` — call it +- `command.signature` — what it takes and returns + +## Timestamps and severity + +A value entering an attribute may carry when it was obtained and how wrong it +is, by arriving as an `Update`: + +```python +from fastcs.attributes import Severity, Update + +async def get_temperature() -> Update[float]: + value, device_time = await protocol.read_with_timestamp() + return Update(readback=value, timestamp=device_time) + +async def get_status() -> Update[float]: + value, fault = await protocol.read_status() + return Update( + readback=value, + severity=Severity.MAJOR if fault else Severity.NO_ALARM, + ) +``` + +A bare value is stamped with the time it arrived and reported as +`Severity.NO_ALARM`. This matters because a device that already knows when a +value was measured — an EPICS record timestamp, a Tango event — otherwise has +nowhere to say so, and the reading silently becomes "whenever FastCS heard +about it". + +`Severity` is a FastCS enum that uses the same strings as EPICS alarm +severities, so a driver or transport speaking EPICS does not have to translate. +It is not EPICS-specific. The value/timestamp/severity trio follows the shape of +bluesky's `Reading` so that the two read the same way, but shares no code with +it. diff --git a/docs/explanations/transports.md b/docs/explanations/transports.md index bf34a2fcc..37edc2770 100644 --- a/docs/explanations/transports.md +++ b/docs/explanations/transports.md @@ -11,6 +11,13 @@ A transport connects a `ControllerAPI` to an external protocol. The `ControllerA - Scan methods (`@scan`) - Sub-controller APIs (hierarchical structure) +A command may take arguments and return a value, and not every protocol can +carry such a call. A transport reads `command.signature` (or the +`argument_types`/`return_datatype`/`is_void` shortcuts) and decides for itself: +serve it, or set `command.enabled = False` and log a warning saying why, so the +rest of the controller is still served. See +[](../how-to/typed-commands.md) for what each transport does. + ## Implementing a Transport Subclass `Transport` and implement `connect()` and `serve()`: @@ -98,14 +105,15 @@ 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 | +| Readback | `add_readback_callback()` | `attr.update(value)` | Publish ↑ | Update protocol representation when the attribute's readback changes | +| Setpoint | `add_setpoint_callback()` | `attr.set(value)` | Publish ↑ | Update protocol representation when the attribute's setpoint changes | +| Update Metadata | `add_update_meta_callback()` | `meta` property changes | Publish ↑ | Update protocol metadata when it changes | +| Set | `attr.set(value)` | Transport receives user input | Set ↓ | Forward write requests from protocol to attribute | -### On Update Callbacks +### Readback Callbacks -Use `add_on_update_callback()` to update the protocol layer when an attribute's value changes. +Use `add_readback_callback()` to update the protocol layer when an attribute's +readback changes. ```python def create_read(name, attribute): @@ -114,75 +122,79 @@ def create_read(name, attribute): async def update_protocol_value(value): protocol_read.post(value) - attribute.add_on_update_callback(update_protocol_value) + attribute.add_readback_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 +### Update Metadata 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). +Use `add_update_meta_callback()` to update protocol metadata when an attribute's +metadata changes. This is useful for protocols that expose that metadata (like EPICS +record fields). ```python def create_read(name, attribute): ... - attribute.add_on_update_callback(update_protocol_value) + attribute.add_readback_callback(update_protocol_value) - def update_protocol_metadata(datatype: DataType): - protocol_read.set_units(datatype.units) - protocol_read.set_limits(datatype.min, datatype.max) + def update_protocol_metadata(meta: Meta): + protocol_read.set_units(meta.get("units")) + limits = meta.get("limits") + if limits is not None: + protocol_read.set_limits(limits.control.low, limits.control.high) - attribute.add_update_datatype_callback(update_protocol_metadata) + attribute.add_update_meta_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`). +The callback receives the new `Meta` and should update the protocol's metadata +representation (e.g., EPICS record fields like `EGU`, `HOPR`, `LOPR`). Every field is +optional, so read them with `.get()`. -### Put +### Setpoint Callbacks -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. +Use `add_setpoint_callback()` to update the protocol layer when an attribute's +setpoint changes. A transport must **not** update its own setpoint display directly - +it registers a callback and lets the attribute drive it, so that every transport +agrees on the setpoint however it was changed (see +[](./decisions/0020-transport-setpoint-mirroring)). ```python def create_write(name, attribute): protocol_setpoint = Protocol(name) - async def handle_write(value): + async def update_protocol_setpoint(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. + async def handle_write(value): + await attribute.set(value) -```python -def create_write(name, attribute): - ... + attribute.add_setpoint_callback(update_protocol_setpoint) +``` - async def update_setpoint_display(value): - protocol_setpoint.post(value) +The callback fires when: - attribute.add_sync_setpoint_callback(update_setpoint_display) -``` +- a write arrives through *any* transport - `set()` caches the requested value and + publishes it before running the setter, so the display updates immediately rather + than waiting for a slow device; +- the setter returns a value, which replaces it with the device's accepted or clamped + value; +- a getter or setter returns `Update(readback=..., setpoint=...)`, for a device that + reports its own setpoint; +- the first readback arrives on an `AttrRW` that has never been written. An `AttrRW` + starts with no known setpoint, so this is what stops a setpoint display sitting at + the datatype's default until someone writes to it. No seeding is required in the + transport. -Sync setpoint callbacks are used in specific cases: +### Set -- 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 +When the transport receives a write request from the protocol, call `await +attribute.set(value)` to forward it to the attribute. This triggers validation, caches +the value as the attribute's `.setpoint` (firing the setpoint callbacks above), and (if +the attribute has one) runs its `setter` to propagate the value to the device. ## Commands diff --git a/docs/explanations/what-is-fastcs.md b/docs/explanations/what-is-fastcs.md index 501a54284..da10059e9 100644 --- a/docs/explanations/what-is-fastcs.md +++ b/docs/explanations/what-is-fastcs.md @@ -22,9 +22,8 @@ without modification. 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. +commands, implements connection logic, and creates periodic polling tasks. Attributes +take `getter`/`setter` callables that read and write values on the device. **Attributes and commands** - typed values (`AttrR`, `AttrW`, `AttrRW`) and callable actions (`@command`) declared on the controller. Attributes represent the device's diff --git a/docs/how-to/arrange-epics-screens.md b/docs/how-to/arrange-epics-screens.md index d7f1303f2..10b9c1649 100644 --- a/docs/how-to/arrange-epics-screens.md +++ b/docs/how-to/arrange-epics-screens.md @@ -16,17 +16,16 @@ 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") + 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") + temperature = AttrR(float, group="Status") + fault_code = AttrR(int, group="Status") @command(group="Actions") async def reset_faults(self) -> None: @@ -51,14 +50,13 @@ 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") + voltage = AttrRW(float, group="Output") + current = AttrRW(float, group="Output") + temperature = AttrR(float, group="Status") @command(group="Actions") async def enable(self) -> None: @@ -66,7 +64,7 @@ class ChannelController(Controller): class MultiChannelPSU(Controller): - total_power = AttrR(Float()) + total_power = AttrR(float) @command() async def disable_all(self) -> None: diff --git a/docs/how-to/fastcs-for-pytango-users.md b/docs/how-to/fastcs-for-pytango-users.md new file mode 100644 index 000000000..1f82ac09c --- /dev/null +++ b/docs/how-to/fastcs-for-pytango-users.md @@ -0,0 +1,157 @@ +# FastCS for PyTango Users + +If you write Tango Device Servers with PyTango, the shape of a FastCS controller +will already be familiar: a class, some attributes, some commands. This page +pairs the PyTango spelling with the FastCS one, so you can carry what you know +across. + +The headline difference is that a FastCS controller is not tied to Tango. The +same class is served over Tango, EPICS (Channel Access or PV Access), REST and +GraphQL - see [](./multiple-transports.md). + +## Hello world + +PyTango's simplest attribute is one decorated getter: + +```python +from tango.server import Device, attribute + + +class PowerSupply(Device): + @attribute + def voltage(self) -> float: + return 2.5 +``` + +FastCS says the same thing with `@attr`: + +```python +from fastcs.attributes import attr +from fastcs.controllers import Controller + + +class PowerSupply(Controller): + @attr + async def voltage(self) -> float: + return 2.5 +``` + +Two differences to notice: + +- The getter is `async`. FastCS controllers run on one event loop, so a getter + that talks to a device awaits it rather than blocking every other attribute. +- The datatype comes from the return annotation. There is no `dtype=` keyword to + keep in step with the code - `-> float` is one real annotation, checked by your + type checker as well as by FastCS. + +## Writing as well as reading + +PyTango pairs a getter with a `@x.write` method (or `@x.setter` in the +`attribute` decorator form). FastCS mirrors `@property`: + +```python +class PowerSupply(Controller): + @attr(units="V", precision=3) + async def voltage(self) -> float: + """Output voltage.""" + return float(await self._conn.query("V?")) + + @voltage.setter + async def voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") +``` + +A getter alone gives you a read-only `AttrR`; adding a setter makes the same +name an `AttrRW`. There is no write-only decorator - a write-only attribute is +rare enough to be written longhand as `AttrW(setter=...)`. + +The getter's docstring becomes the attribute's description, and keyword +arguments to `@attr` are the attribute's metadata - `units`, `precision`, +`limits`, `group`, `description`. They are checked against the datatype the +getter returns, so `precision` on a `-> str` getter is an error rather than a +field that is silently ignored. + +:::{note} +Type checkers special-case the builtin `property` but not decorators that +imitate it, so pyright reports the getter as *obscured by a declaration of the +same name*, and mypy as *already defined*. The two declarations are deliberate, +so silence it at the getter: + +```python +@attr(units="V") +async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + ... +``` + +Only read-write attributes need this. A read-only `@attr` declares its name +once and needs nothing. +::: + +## Deciding when a value is read + +PyTango polls an attribute on a period configured per device, outside the code. +In FastCS the schedule is part of the declaration, and is the same +`Polled`/`NotPolled` vocabulary the procedural form uses: + +```python +from fastcs.attributes import NotPolled, Polled, attr + + +class PowerSupply(Controller): + @attr(Polled(period=0.5), units="V") + async def voltage(self) -> float: + """Read every half second, because the device changes it.""" + return float(await self._conn.query("V?")) + + @attr + async def serial_number(self) -> str: + """Read once, when the controller connects.""" + return await self._conn.query("*IDN?") + + @attr(NotPolled()) + async def last_error(self) -> str: + """Never read on a schedule - only when something asks for it.""" + return await self._conn.query("ERR?") +``` + +A bare `@attr` means read once, at connect - the same default a bare +`getter=` has. See [](./update-attributes-from-device.md) for the whole picture, +including devices that push values at you rather than being polled. + +## Commands + +PyTango's `@command` and FastCS's `@command` line up directly, including typed +arguments and return values: + +```python +from fastcs.methods import command + + +class PowerSupply(Controller): + @command() + async def reset(self) -> None: + """Return the supply to its power-on state.""" + await self._conn.send("*RST") +``` + +See [](./typed-commands.md) for arguments and return values, and which +transports can serve them. + +## When not to use `@attr` + +`@attr` is the simple case: one attribute, one device call, known at the time +you write the class. It is sugar over the procedural form, and there are two +other spellings for when it stops fitting: + +- **The attribute needs more than a getter and a setter** - a shared connection + object, several attributes built in a loop, values that come from one + request - build them in `__init__` with `AttrR(getter=...)` / + `AttrRW(getter=..., setter=...)` directly. `@attr` degrades into exactly that + form, so nothing is lost by moving. +- **The device describes itself** - the attributes are discovered by asking the + device what it has, rather than written out. Declare what your code refers to + as type hints and let the controller fill them in at initialisation. See + [](../tutorials/dynamic-drivers.md). + +There is no free-function `attr()` factory: outside a class body, write the +constructor. diff --git a/docs/how-to/table-waveform-data.md b/docs/how-to/table-waveform-data.md index d7a7572bc..a9cb4667d 100644 --- a/docs/how-to/table-waveform-data.md +++ b/docs/how-to/table-waveform-data.md @@ -1,53 +1,55 @@ -# Work with Table and Waveform Data +# Work with Table and Array Data -This guide shows how to use `Waveform` and `Table` datatypes for array-based data. +This guide shows how to use the `Array1D` and `Table` datatypes for array-based data. -## Waveform - Homogeneous Arrays +## Array1D - Homogeneous Arrays -Use `Waveform` for numpy arrays of a single data type (spectra, time series, images). - -### Basic 1D Waveform +Use `Array1D` for numpy arrays of a single element type (spectra, time series, images). ```python import numpy as np from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import Controller -from fastcs.datatypes import Waveform +from fastcs.datatypes import Array1D class SpectrumController(Controller): # 1D array of 1000 float64 values - spectrum: AttrR[np.ndarray] = AttrR(Waveform(np.float64, shape=(1000,))) + spectrum = AttrR(Array1D[np.float64], shape=(1000,)) - # Writable waveform - setpoints: AttrRW[np.ndarray] = AttrRW(Waveform(np.float64, shape=(100,))) + # Writable array + setpoints = AttrRW(Array1D[np.float64], shape=(100,)) ``` -### 2D Waveform (Images) +### 2D Arrays (Images) + +`Array1D` is, as the name says, one dimensional. An array of higher rank has no +ophyd-async-compatible spelling, so write it as `np.ndarray` with an explicit +`array_dtype`: ```python class CameraController(Controller): # 2D array for images (max 1024x1024 uint16) - image: AttrR[np.ndarray] = AttrR(Waveform(np.uint16, shape=(1024, 1024))) + image = AttrR(np.ndarray, array_dtype=np.uint16, shape=(1024, 1024)) # Smaller region of interest - roi: AttrRW[np.ndarray] = AttrRW(Waveform(np.uint16, shape=(256, 256))) + roi = AttrRW(np.ndarray, array_dtype=np.uint16, shape=(256, 256)) ``` -### Waveform Parameters +### Array Metadata -| Parameter | Type | Default | Description | +| Field | Type | Default | Description | |-----------|------|---------|-------------| -| `array_dtype` | `DTypeLike` | (required) | Numpy dtype (`np.float64`, `np.int32`, etc.) | +| `array_dtype` | `DTypeLike` | from the datatype subscript | Numpy element type (`np.float64`, `np.int32`, etc.) | | `shape` | `tuple[int, ...]` | `(2000,)` | Maximum array dimensions | -### Updating Waveforms +### Updating Arrays ```python from fastcs.methods import scan class SpectrumController(Controller): - spectrum: AttrR[np.ndarray] = AttrR(Waveform(np.float64, shape=(1000,))) + spectrum = AttrR(Array1D[np.float64], shape=(1000,)) @scan(period=0.1) async def read_spectrum(self): @@ -60,16 +62,16 @@ class SpectrumController(Controller): ### Shape Validation -Waveforms validate that data fits within the declared shape: +Arrays validate that data fits within the declared shape: ```python -wave = Waveform(np.float64, shape=(100,)) +spectrum = AttrR(Array1D[np.float64], shape=(100,)) # OK - fits within shape -wave.validate(np.array([1.0, 2.0, 3.0])) +spectrum.validate(np.array([1.0, 2.0, 3.0])) # Error - exceeds maximum shape -wave.validate(np.arange(200)) # ValueError: shape (200,) exceeds maximum (100,) +spectrum.validate(np.arange(200)) # ValueError: shape (200,) exceeds maximum (100,) ``` ## Table - Structured Arrays @@ -87,16 +89,19 @@ 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_), - ])) + results = AttrR( + Table, + structured_dtype=[ + ("name", "S32"), # 32-character string + ("value", np.float64), + ("valid", np.bool_), + ], + ) ``` -### Table Parameters +### Table Metadata -| Parameter | Type | Description | +| Field | Type | Description | |-----------|------|-------------| | `structured_dtype` | `list[tuple[str, DTypeLike]]` | List of (name, dtype) tuples | @@ -108,11 +113,14 @@ 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"), - ])) + channel_data = AttrR( + Table, + structured_dtype=[ + ("channel", np.int32), + ("temperature", np.float64), + ("status", "S10"), + ], + ) # Create data using numpy structured array data = np.array([ @@ -129,7 +137,7 @@ await controller.channel_data.update(data) ```python # Get the table -table = controller.results.get() +table = controller.results.readback # Access by column name names = table["name"] diff --git a/docs/how-to/typed-commands.md b/docs/how-to/typed-commands.md new file mode 100644 index 000000000..b85f34655 --- /dev/null +++ b/docs/how-to/typed-commands.md @@ -0,0 +1,101 @@ +# Give a Command Arguments and a Return Value + +A `@command` may take positional arguments and give a value back. Both are +declared the ordinary way - by annotating the method - and both are optional and +independent, so a command can take arguments and return nothing, return +something and take nothing, or do both. + +```python +from fastcs.controllers import Controller +from fastcs.methods import command + +class Stage(Controller): + @command() + async def stop(self) -> None: + """Void: no arguments, no return value.""" + await self._protocol.stop() + + @command() + async def move_to(self, position: float, wait: bool) -> None: + """Two positional arguments.""" + await self._protocol.move(position, wait) + + @command() + async def measure(self) -> float: + """A return value.""" + return await self._protocol.read_position() +``` + +## What a command may take and return + +Arguments and return values are `bool`, `int`, `float`, `str`, or an +`enum.Enum` subclass - the same python types an attribute holds, minus arrays +and tables. Everything must be annotated: a command's signature is what +transports read to decide how to expose it, so it has to be fully known. + +```python +@command() +async def move_to(self, position): # TypeError: no type annotation + ... + +@command() +async def plot(self, trace: list[float]): # TypeError: unsupported type + ... +``` + +Arguments are positional. Keyword-only arguments, `*args` and `**kwargs` are +rejected. + +An array-valued command has an attribute-shaped alternative: write the array to +an `AttrW` and trigger a void command, rather than passing it as an argument. + +## Which transports serve them + +Not every protocol can carry a typed call, so each transport declares what it +can do rather than the framework assuming they are all alike. A command a +transport cannot serve is **skipped with a warning at start-up** - the rest of +the controller is still served. + +| Transport | Void command | Arguments | Return value | +| --------- | ------------ | --------- | ------------ | +| REST | ✅ | ✅ any number, as a JSON body | ✅ as `{"value": …}` | +| GraphQL | ✅ | ✅ any number, as mutation arguments | ✅ the mutation result | +| Tango | ✅ | ⚠️ at most one, and not an enum | ⚠️ not an enum | +| EPICS CA | ✅ | ❌ | ❌ | +| EPICS PVA | ✅ | ❌ | ❌ | + +The EPICS transports serve a command as a single "do it" PV. There is no PV +representation of "call with these arguments and give me this back" that is not +already a set of attributes, so a typed command has nothing to map onto. Tango +commands carry at most one input value, which is a limit of the protocol. + +If a command must be reachable over EPICS, keep it void and put its arguments +and results on attributes: + +```python +class Stage(Controller): + target = AttrRW(float) + last_position = AttrR(float) + + @command() + async def move(self) -> None: + await self._protocol.move(self.target.setpoint) + await self.last_position.update(await self._protocol.read_position()) +``` + +## Reading a command's signature + +A transport - or anything else walking a `ControllerAPI` - gets the whole +picture from the command itself: + +```python +command = controller_api.command_methods["move_to"] + +command.signature # (position: float, wait: bool) -> None +command.argument_types # (float, bool) +command.return_datatype # None +command.is_void # False +``` + +`signature` is the bound signature, without `self`, so it is what a caller +would actually pass. diff --git a/docs/how-to/update-attributes-from-device.md b/docs/how-to/update-attributes-from-device.md index 895eb5f2a..b5b710965 100644 --- a/docs/how-to/update-attributes-from-device.md +++ b/docs/how-to/update-attributes-from-device.md @@ -3,129 +3,119 @@ 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` +## Poll via a Getter -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`. +Use this pattern when each attribute maps to an independent request to the device. Give +the attribute a `getter` wrapped in `Polled` and FastCS will call it periodically as a +background task, at the period given. -Define an `AttributeIORef` with an `update_period` and implement `AttributeIO.update` -to query the device and call `attr.update` with the result: +Write a getter that queries the device and returns the value - the framework caches it +and calls any update callbacks; there's no need to call `attr.update` yourself: ```python -from dataclasses import KW_ONLY, dataclass - -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, NotPolled, Polled 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]): +class MyController(Controller): def __init__(self, connection): - super().__init__() self._connection = connection + super().__init__() - 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())) + self.temperature = AttrR( + float, getter=Polled(self._get_temperature, period=0.5) + ) + self.setpoint = AttrRW( + float, + getter=Polled(self._get_setpoint, period=1.0), + setter=self._set_setpoint, + ) + self.label = AttrR(str, getter=NotPolled(self._get_label)) - async def send(self, attr: AttrW[float, MyDeviceIORef], value: float): - await self._connection.send_command(f"{attr.io_ref.register}={value}\r\n") + async def _get_temperature(self) -> float: + response = await self._connection.send_query("T?\r\n") + return float(response.strip()) + async def _get_setpoint(self) -> float: + response = await self._connection.send_query("S?\r\n") + return float(response.strip()) -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)) + async def _set_setpoint(self, value: float) -> None: + await self._connection.send_command(f"S={value}\r\n") - def __init__(self, connection): - super().__init__(ios=[MyDeviceIO(connection)]) + async def _get_label(self) -> str: + response = await self._connection.send_query("L?\r\n") + return response.strip() ``` -Setting `update_period` to: +How the getter is passed decides when it is called: -- 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. +- A bare getter (`getter=self._get_label`) — the `ONCE` schedule: read when the + controller connects, and not again. Use it for values that only change because + you changed them, such as writable configuration the device holds for you. +- `Polled(getter, period=0.5)` — polls at that interval in seconds. Use it for + values the device changes on its own, such as readings and status. +- `NotPolled(getter)` — never read on a schedule; the attribute value is only set + explicitly (e.g. from a scan method or subscription callback), or read on demand + via `await attr.poll()`. This differs from giving no getter at all, which leaves + nothing to read on demand. -## Initial Read with Event-Driven Updates from Puts +`ONCE` is the default when a getter is given, so polling is opted into per +attribute rather than being something you have to remember to switch off. -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. +## Initial Read with Event-Driven Updates from Sets -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: +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. -```python -from collections.abc import Awaitable, Callable -from dataclasses import KW_ONLY, dataclass +Pass the getter bare, without `Polled`, so it runs once on startup and not again. +Then, in the setter, parse the device's response and call `.update()` directly on any +sibling attributes whose values have changed: -from fastcs import ONCE -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +```python +from fastcs.attributes import AttrR, AttrRW 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__() +class MyController(Controller): + def __init__(self, connection): 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())) + super().__init__() - 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" + self.setpoint = AttrRW( + float, getter=self._get_setpoint, setter=self._set_setpoint ) - if self._on_put_response is not None: - await self._on_put_response(response) + self.actual_temperature = AttrR(float, getter=self._get_actual_temperature) + self.power = AttrR(float, getter=self._get_power) + self.status = AttrR(float, getter=self._get_status) + async def _get_setpoint(self) -> float: + return float((await self._connection.send_query("S?\r\n")).strip()) -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: + async def _set_setpoint(self, value: float) -> None: + # Device responds with a snapshot of all current values after a set + response = await self._connection.send_query(f"S={value}\r\n") 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)) + + async def _get_actual_temperature(self) -> float: + return float((await self._connection.send_query("T?\r\n")).strip()) + + async def _get_power(self) -> float: + return float((await self._connection.send_query("P?\r\n")).strip()) + + async def _get_status(self) -> float: + return float((await self._connection.send_query("X?\r\n")).strip()) ``` -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. +Attributes that are updated as a side-effect of a set can still take a bare getter, +so they also get their initial value on startup. Use `NotPolled(getter)` instead if +the device's response to the set is the only source of truth and no initial poll is +needed. ## Batched Updates via a Scan Method @@ -133,20 +123,20 @@ Use this pattern when the device returns values for multiple attributes in a sin 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. +Attributes that are updated this way do not need a `getter` at all, because +the scan method drives the updates directly, rather than each attribute polling +independently. ```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 + voltage = AttrR(float) # No getter — updated by parent scan method def __init__(self, index: int, connection): super().__init__(f"Ch{index:02d}") @@ -178,66 +168,52 @@ class MultiChannelController(Controller): 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` +### Scan as a cache for getters 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. +shared cache, and each attribute's own getter (polled independently) reads from that +cache rather than querying the device - 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.attributes import 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 +class ChannelController(Controller): + def __init__(self, index: int, cache: dict[int, float]): + self._index = index + self._cache = cache + super().__init__(f"Ch{index:02d}") - 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) + self.voltage = AttrR(float, getter=Polled(self._get_voltage, period=0.1)) - -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)) + async def _get_voltage(self) -> float: + return self._cache.get(self._index, 0.0) class MultiChannelController(Controller): def __init__(self, channel_count: int, connection): self._connection = connection - self._channel_io = ChannelIO() + self._cache: dict[int, float] = {} super().__init__() + self._channels: list[ChannelController] = [] for i in range(channel_count): - self.add_sub_controller(f"Ch{i:02d}", ChannelController(i, self._channel_io)) + ch = ChannelController(i, self._cache) + self._channels.append(ch) + self.add_sub_controller(f"Ch{i:02d}", ch) @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)))) + self._cache.clear() + self._cache.update(enumerate(map(float, voltages))) ``` ## Subscription Callbacks @@ -251,11 +227,10 @@ import asyncio from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import Float class SubscriptionController(Controller): - temperature = AttrR(Float()) + temperature = AttrR(float) def __init__(self, subscription_client): super().__init__() diff --git a/docs/how-to/wait-methods.md b/docs/how-to/wait-methods.md index d61fb8bec..ee59bc67b 100644 --- a/docs/how-to/wait-methods.md +++ b/docs/how-to/wait-methods.md @@ -10,17 +10,16 @@ Use `wait_for_value()` to pause execution until an attribute reaches an exact va ```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()) + 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() + target = self.target.readback # Start the move (implementation depends on your device) await self._start_move(target) @@ -37,11 +36,10 @@ 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()) + temperature: AttrR[float] = AttrR(float) @command() async def wait_for_stable(self): @@ -89,13 +87,12 @@ 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()) + x_position = AttrR(float) + y_position = AttrR(float) + z_position = AttrR(float) @command() async def move_all_and_wait(self): diff --git a/docs/snippets/dynamic.py b/docs/snippets/dynamic.py index 7dde6dfe5..4e7c00e72 100644 --- a/docs/snippets/dynamic.py +++ b/docs/snippets/dynamic.py @@ -1,23 +1,31 @@ import json -from dataclasses import KW_ONLY, dataclass from typing import Any, Literal, TypeVar from pydantic import BaseModel, ConfigDict, ValidationError -from fastcs.attributes import ( - Attribute, - AttributeIO, - AttributeIORef, - AttrR, - AttrRW, - AttrW, -) +from fastcs.attributes import Attribute, AttrR, AttrRW from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Bool, DataType, Float, Int, String +from fastcs.datatypes import DType from fastcs.launch import FastCS from fastcs.transports.epics.ca import EpicsCATransport +ValueT = TypeVar("ValueT") + + +class TemperatureProtocol: + def __init__(self, connection: IPConnection): + self._connection = connection + + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}={dtype(value)}" # type: ignore[call-arg] + await self._connection.send_command(f"{command}\r\n") + + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class TemperatureControllerParameter(BaseModel): model_config = ConfigDict(extra="forbid") @@ -27,19 +35,21 @@ class TemperatureControllerParameter(BaseModel): access_mode: Literal["r", "rw"] @property - def fastcs_datatype(self) -> DataType: + def fastcs_datatype(self) -> type[DType]: match self.type: case "bool": - return Bool() + return bool case "int": - return Int() + return int case "float": - return Float() + return float case "str": - return String() + return str -def create_attributes(parameters: dict[str, Any]) -> dict[str, Attribute]: +def create_attributes( + parameters: dict[str, Any], protocol: TemperatureProtocol +) -> dict[str, Attribute]: attributes: dict[str, Attribute] = {} for name, parameter in parameters.items(): name = name.replace(" ", "_").lower() @@ -50,46 +60,23 @@ def create_attributes(parameters: dict[str, Any]) -> dict[str, Attribute]: print(f"Failed to validate parameter '{parameter}'\n{e}") continue - io_ref = TemperatureControllerAttributeIORef(parameter.command) + datatype = parameter.fastcs_datatype + command = parameter.command + + async def getter(command=command, dtype=datatype): + return await protocol.send_query(command, dtype) + match parameter.access_mode: case "r": - attributes[name] = AttrR(parameter.fastcs_datatype, io_ref=io_ref) + attributes[name] = AttrR(datatype, getter=getter) case "rw": - attributes[name] = AttrRW(parameter.fastcs_datatype, io_ref=io_ref) - - return attributes - -NumberT = TypeVar("NumberT", int, float) + async def setter(value, command=command, dtype=datatype): + await protocol.send_command(command, value, dtype) + attributes[name] = AttrRW(datatype, getter=getter, setter=setter) -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): - def __init__(self, connection: IPConnection): - super().__init__() - - self._connection = connection - - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}={attr.dtype(value)}" - await self._connection.send_command(f"{command}\r\n") + return attributes class TemperatureRampController(Controller): @@ -97,13 +84,16 @@ def __init__( self, index: int, parameters: dict[str, TemperatureControllerParameter], - io: TemperatureControllerAttributeIO, + protocol: TemperatureProtocol, ): self._parameters = parameters - super().__init__(f"Ramp{index}", ios=[io]) + self._protocol = protocol + super().__init__(f"Ramp{index}") async def initialise(self): - for name, attribute in create_attributes(self._parameters).items(): + for name, attribute in create_attributes( + self._parameters, self._protocol + ).items(): self.add_attribute(name, attribute) @@ -111,9 +101,9 @@ class TemperatureController(Controller): def __init__(self, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - self._io = TemperatureControllerAttributeIO(self._connection) - super().__init__(ios=[self._io]) + super().__init__() async def connect(self): await self._connection.connect(self._ip_settings) @@ -125,12 +115,12 @@ async def initialise(self): ramps_api = api.pop("Ramps") - for name, attribute in create_attributes(api).items(): + for name, attribute in create_attributes(api, self._protocol).items(): self.add_attribute(name, attribute) for idx, ramp_parameters in enumerate(ramps_api): ramp_controller = TemperatureRampController( - idx + 1, ramp_parameters, self._io + idx + 1, ramp_parameters, self._protocol ) await ramp_controller.initialise() self.add_sub_controller(f"Ramp{idx + 1:02d}", ramp_controller) diff --git a/docs/snippets/static03.py b/docs/snippets/static03.py index ca73b357c..82b2ee5fb 100644 --- a/docs/snippets/static03.py +++ b/docs/snippets/static03.py @@ -1,11 +1,10 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS class TemperatureController(Controller): - device_id = AttrR(String()) + device_id = AttrR(str) fastcs = FastCS(TemperatureController(), []) diff --git a/docs/snippets/static04.py b/docs/snippets/static04.py index 345794ea9..c52801fab 100644 --- a/docs/snippets/static04.py +++ b/docs/snippets/static04.py @@ -1,12 +1,11 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS from fastcs.transports.epics.ca.transport import EpicsCATransport class TemperatureController(Controller): - device_id = AttrR(String()) + device_id = AttrR(str) epics_ca = EpicsCATransport() diff --git a/docs/snippets/static05.py b/docs/snippets/static05.py index 0d3b610af..2851dc6d0 100644 --- a/docs/snippets/static05.py +++ b/docs/snippets/static05.py @@ -2,14 +2,13 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport class TemperatureController(Controller): - device_id = AttrR(String()) + device_id = AttrR(str) gui_options = EpicsGUIOptions(output_dir=Path("."), title="Demo Temperature Controller") diff --git a/docs/snippets/static06.py b/docs/snippets/static06.py index 269e3309e..f7bd33d15 100644 --- a/docs/snippets/static06.py +++ b/docs/snippets/static06.py @@ -3,14 +3,13 @@ from fastcs.attributes import AttrR from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport class TemperatureController(Controller): - device_id = AttrR(String()) + device_id = AttrR(str) def __init__(self, settings: IPConnectionSettings): super().__init__() diff --git a/docs/snippets/static07.py b/docs/snippets/static07.py index cac5549d3..2aea3bb76 100644 --- a/docs/snippets/static07.py +++ b/docs/snippets/static07.py @@ -1,44 +1,25 @@ -from dataclasses import dataclass from pathlib import Path -from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.attributes import AttrR, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) - - -@dataclass -class IDAttributeIORef(AttributeIORef): - update_period: float | None = 0.2 - - -class IDAttributeIO(AttributeIO[NumberT, IDAttributeIORef]): - def __init__(self, connection: IPConnection): - super().__init__() - - self._connection = connection - - async def update(self, attr: AttrR[NumberT, IDAttributeIORef]): - response = await self._connection.send_query("ID?\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=IDAttributeIORef()) - def __init__(self, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() - super().__init__(ios=[IDAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + + async def _get_device_id(self) -> str: + response = await self._connection.send_query("ID?\r\n") + return response.strip("\r\n") async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static08.py b/docs/snippets/static08.py index 5382fa3c9..95363380b 100644 --- a/docs/snippets/static08.py +++ b/docs/snippets/static08.py @@ -1,50 +1,47 @@ -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.attributes import AttrR, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Float, String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): - def __init__(self, connection: IPConnection): - super().__init__() - +class TemperatureProtocol: + def __init__(self, connection: IPConnection, suffix: str = ""): self._connection = connection + self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] + await self._connection.send_command(f"{command}\r\n") - await attr.update(attr.dtype(value)) + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - def __init__(self, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) + + super().__init__() + + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) + + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static09.py b/docs/snippets/static09.py index dc5bb9d54..10c07b334 100644 --- a/docs/snippets/static09.py +++ b/docs/snippets/static09.py @@ -1,57 +1,58 @@ -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Float, String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): - def __init__(self, connection: IPConnection): - super().__init__() - +class TemperatureProtocol: + def __init__(self, connection: IPConnection, suffix: str = ""): self._connection = connection + self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) - def __init__(self, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) + + super().__init__() + + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + float, + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) + + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static10.py b/docs/snippets/static10.py index 24f6d523d..e6ea3292d 100644 --- a/docs/snippets/static10.py +++ b/docs/snippets/static10.py @@ -1,69 +1,72 @@ -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Float, Int, String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] -class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) +class TemperatureRampController(Controller): def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start + ) + self.end = AttrRW( + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) + + super().__init__() - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + float, + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -71,6 +74,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static11.py b/docs/snippets/static11.py index e07ed5a41..222d0cdda 100644 --- a/docs/snippets/static11.py +++ b/docs/snippets/static11.py @@ -1,48 +1,31 @@ import enum -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class OnOffEnum(enum.StrEnum): Off = "0" @@ -50,27 +33,57 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start + ) + self.end = AttrRW( + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end + ) + self.enabled = AttrRW( + OnOffEnum, + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, ) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + float, + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -78,6 +91,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static12.py b/docs/snippets/static12.py index 9f1f2af14..db7ced42c 100644 --- a/docs/snippets/static12.py +++ b/docs/snippets/static12.py @@ -1,50 +1,33 @@ import enum import json -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.methods import scan from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class OnOffEnum(enum.StrEnum): Off = "0" @@ -52,30 +35,66 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - target = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("T")) - actual = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("A")) - voltage = AttrR(Float()) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start + ) + self.end = AttrRW( + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) + self.enabled = AttrRW( + OnOffEnum, + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, + ) + self.target = AttrR(float, getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(float) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + async def _get_target(self) -> float: + return await self._protocol.send_query("T", float) + + async def _get_actual(self) -> float: + return await self._protocol.send_query("A", float) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + float, + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -83,6 +102,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static13.py b/docs/snippets/static13.py index b2036c66f..420acd994 100644 --- a/docs/snippets/static13.py +++ b/docs/snippets/static13.py @@ -1,51 +1,34 @@ import asyncio import enum import json -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.methods import command, scan from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class OnOffEnum(enum.StrEnum): Off = "0" @@ -53,30 +36,66 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - target = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("T")) - actual = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("A")) - voltage = AttrR(Float()) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start + ) + self.end = AttrRW( + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) + self.enabled = AttrRW( + OnOffEnum, + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, + ) + self.target = AttrR(float, getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(float) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + async def _get_target(self) -> float: + return await self._protocol.send_query("T", float) + + async def _get_actual(self) -> float: + return await self._protocol.send_query("A", float) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + float, + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -84,6 +103,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) @@ -98,7 +129,7 @@ async def update_voltages(self): @command() async def disable_all(self) -> None: for rc in self._ramp_controllers: - await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True) + await rc.enabled.set(OnOffEnum.Off) # TODO: The requests all get concatenated and the sim doesn't handle it await asyncio.sleep(0.1) diff --git a/docs/snippets/static14.py b/docs/snippets/static14.py index f54c93d3d..9e25a6418 100644 --- a/docs/snippets/static14.py +++ b/docs/snippets/static14.py @@ -1,54 +1,38 @@ import asyncio import enum import json -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.logging import configure_logging, logger from fastcs.methods import command, scan from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] - logger.info("Sending attribute value", command=command, attribute=attr) + logger.info("Sending attribute value", command=command) await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class OnOffEnum(enum.StrEnum): Off = "0" @@ -56,30 +40,66 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - target = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("T")) - actual = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("A")) - voltage = AttrR(Float()) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start + ) + self.end = AttrRW( + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) + self.enabled = AttrRW( + OnOffEnum, + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, + ) + self.target = AttrR(float, getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(float) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + async def _get_target(self) -> float: + return await self._protocol.send_query("T", float) + + async def _get_actual(self) -> float: + return await self._protocol.send_query("A", float) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + float, + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -87,6 +107,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) @@ -102,7 +134,7 @@ async def update_voltages(self): async def disable_all(self) -> None: self.log_event("Disabling all ramps") for rc in self._ramp_controllers: - await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True) + await rc.enabled.set(OnOffEnum.Off) # TODO: The requests all get concatenated and the sim doesn't handle it await asyncio.sleep(0.1) diff --git a/docs/snippets/static15.py b/docs/snippets/static15.py index aa2d53a92..ac1a1d0d9 100644 --- a/docs/snippets/static15.py +++ b/docs/snippets/static15.py @@ -1,56 +1,45 @@ import asyncio import enum import json -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.logging import LogLevel, configure_logging, logger from fastcs.methods import command, scan +from fastcs.tracer import Tracer from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol(Tracer): def __init__(self, connection: IPConnection, suffix: str = ""): super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] - self.log_event("Query for attribute", query=query, response=value, topic=attr) + logger.info("Sending attribute value", command=command) - await attr.update(attr.dtype(value)) + await self._connection.send_command(f"{command}\r\n") - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_query( + self, param: str, dtype: type[ValueT], topic: Tracer | None = None + ) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + value = dtype(response.strip("\r\n")) # type: ignore[call-arg] - logger.info("Sending attribute value", command=command, attribute=attr) + self.log_event("Query for attribute", topic=topic, query=query, response=value) - await self._connection.send_command(f"{command}\r\n") + return value class OnOffEnum(enum.StrEnum): @@ -59,30 +48,66 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - target = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("T")) - actual = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("A")) - voltage = AttrR(Float()) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start + ) + self.end = AttrRW( + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end + ) + self.enabled = AttrRW( + OnOffEnum, + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, ) + self.target = AttrR(float, getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(float) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int, topic=self.start) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int, topic=self.end) + + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str, topic=self.enabled)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + async def _get_target(self) -> float: + return await self._protocol.send_query("T", float, topic=self.target) + + async def _get_actual(self) -> float: + return await self._protocol.send_query("A", float, topic=self.actual) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + float, + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -90,6 +115,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str, topic=self.device_id) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float, topic=self.power) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float, topic=self.ramp_rate) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) @@ -105,7 +142,7 @@ async def update_voltages(self): async def disable_all(self) -> None: self.log_event("Disabling all ramps") for rc in self._ramp_controllers: - await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True) + await rc.enabled.set(OnOffEnum.Off) # TODO: The requests all get concatenated and the sim doesn't handle it await asyncio.sleep(0.1) diff --git a/docs/tutorials/dynamic-drivers.md b/docs/tutorials/dynamic-drivers.md index 8b02b1e7b..ae55dc608 100644 --- a/docs/tutorials/dynamic-drivers.md +++ b/docs/tutorials/dynamic-drivers.md @@ -43,27 +43,27 @@ implement an `initialise` method to create these dynamically instead. Create a pydantic model to validate the response from the device :::{literalinclude} /snippets/dynamic.py -:lines: 5,18-35 +:lines: 4,30-47 ::: Create a function to parse the dictionary, validate the entries against the model and -create `Attributes`. +create `Attributes`. Each attribute gets a `getter` (and, if writable, a `setter`) built +as a small closure over its `command` and the shared `TemperatureProtocol` instance, +rather than an IO reference - dynamically-created attributes need their IO wired up at +construction time just like statically-declared ones do. :::{literalinclude} /snippets/dynamic.py -:lines: 38-56 +:lines: 50-79 ::: Update the controllers to not define attributes statically and implement initialise -methods to create these attributes dynamically. +methods to create these attributes dynamically, passing the shared `TemperatureProtocol` +down to `create_attributes` so the dynamically-created getters/setters can use it. :::{literalinclude} /snippets/dynamic.py -:lines: 91-131 +:lines: 82-128 ::: -The `suffix` field should also be removed from `TemperatureController` and -`TemperatureRampController` and then not used in `TemperatureControllerAttributeIO` -because the `command` field on `TemperatureControllerParameter` includes this. - TODO: Add `enabled` back in to `TemperatureRampController` and recreate `disable_all` to demonstrate validation of introspected Attributes. diff --git a/docs/tutorials/static-drivers.md b/docs/tutorials/static-drivers.md index f28f9b603..e90248e9f 100644 --- a/docs/tutorials/static-drivers.md +++ b/docs/tutorials/static-drivers.md @@ -80,9 +80,9 @@ doesn't have a connection. ::: In [1]: controller.device_id -Out[1]: AttrR(String()) +Out[1]: AttrR(name=device_id, dtype=str) -In [2]: controller.device_id.get() +In [2]: controller.device_id.readback Out[2]: '' ::: @@ -139,8 +139,10 @@ The `demo.bob` will have been created in the directory the application was run f ## FastCS Device Connection The `Attributes` of a FastCS `Controller` need some IO with the device in order to get -and set values. This is implemented with `AttributeIO`s and connections. Generally each -driver implements its own IO and connection logic, but there are some built in options. +and set values. This is implemented with plain `getter`/`setter` callables passed to the +`Attribute` constructor, together with a connection. Generally each driver implements +its own getter/setter logic and connection, but there are some built in connection +options. Update the controller to create an `IPConnection` to communicate with the simulator over TCP and implement a `connect` method that establishes the connection. The `connect` @@ -165,27 +167,29 @@ The application will now fail to connect if the demo simulation is not running. ::: The `Controller` has now established a connection with the simulator. This connection -can be passed to an `AttributeIO` to enable it to query the device API and update the -value in the `device_id` attribute. Create a `TemperatureControllerAttributeIO` child -class and implement the `update` method to query the device and set the value of the -attribute, and create a `TemperatureControllerAttributeIORef` and pass an instance of -it to the `device_id` attribute to tell the controller what io to use to update it. +can be used by a `getter` callable to query the device API and update the value in the +`device_id` attribute. Note that the `Attribute` now has to be created in `__init__`, +after the connection exists, rather than as a class body instance - a getter needs to +close over a live connection, which doesn't exist yet when the class body is evaluated. +Write a `_get_device_id` method that queries the device and returns its value, and pass +it to `device_id` as `getter`. :::{note} -The `update_period` property tells the base class how often to call `update` +Passing the getter bare, as here, means it is called once at start up. Wrap it in +`Polled(getter, period=...)` to have the base class call it repeatedly instead. ::: ::::{admonition} Code 7 :class: dropdown, hint :::{literalinclude} /snippets/static07.py -:emphasize-lines: 1,3,5-6,15-33,37,43 +:emphasize-lines: 13-19,21-23 ::: :::: :::{note} -In the `update` method, errors won't crash the application, but it prints them to the +If a getter raises, it won't crash the application, but it prints the error to the terminal. - `Update loop ... stopped:` ::: @@ -201,26 +205,28 @@ DEMO:DeviceId SIMTCONT123 The simulator supports many other commands, for example it reports the total power currently being drawn with the `P` command. This can be exposed by adding another -`AttrR` with a `Float` datatype, but the IO only supports the `ID` command to get the -device ID. This new attribute could have its own IO, but it is similar enough that the -existing IO can be support both. +`AttrR` with a `Float` datatype, but so far the getter for `device_id` only knows how to +send the `ID` command. This new attribute could get its own bespoke getter, but the +query-building logic is similar enough between commands that it is worth factoring out. -Modify the IO ref to take a `name` string and update the IO to use it in the query -string sent to the device. Create a new attribute to read the power usage using this. +Extract a small `TemperatureProtocol` class that knows how to send a query or a command +for a given parameter name, casting the response to the right python type. Each +attribute then gets a thin getter method that just names the parameter and delegates to +the protocol. :::{note} All responses from the `IPConnection` are strings. This is fine for the `ID` command -because the value is actually a string, but for `P` the value is a float, so the -`update` methods needs to explicitly cast to the correct type. It can use -`Attribute.dtype` to call the builtin for its datatype - e.g. `int`, `float`, `str`, -etc. +because the value is actually a string, but for `P` the value is a float, so +`TemperatureProtocol.send_query` needs to explicitly cast to the correct type. It takes +the target python type as an argument (e.g. `int`, `float`, `str`) and calls it as a +constructor to perform the cast. ::: :::{admonition} Code 8 :class: dropdown, hint :::{literalinclude} /snippets/static08.py -:emphasize-lines: 10,19-21,33-38,42-43 +:emphasize-lines: 12,15-27,34,38-39,41-45 ::: :::: @@ -229,14 +235,14 @@ Now the IOC has two PVs being polled periodically. The new PV will be visible in Phoebus UI on refresh (right-click). `DEMO:Power` will read as `0` because the simulator is not currently running a ramp. To do that the controller needs to be able to set values on the device, as well as read them back. The ramp rate of the temperature can be -read with the `R` command and set with the `R=...` command. This means the IO also needs -a `send` method to send values to the device. +read with the `R` command and set with the `R=...` command. This means the protocol also +needs a way to send values to the device, which `send_command` already provides. -Update the IO to implement `send` and then add a new `AttrRW` with type `Float` to get -and set the ramp rate. +Add a new `AttrRW` with type `Float` to get and set the ramp rate, giving it both a +`getter` and a `setter`. :::{note} -The set commands do not return a response, so use the `send_command` method instead of +The set commands do not return a response, so the setter uses `send_command` instead of `send_query`. ::: @@ -244,7 +250,7 @@ The set commands do not return a response, so use the `send_command` method inst :class: dropdown, hint :::{literalinclude} /snippets/static09.py -:emphasize-lines: 7,40-44,48-50 +:emphasize-lines: 4,40-45,53-57 ::: :::: @@ -279,16 +285,17 @@ has. This can be done with the use of sub controllers. Controllers can be arbitr nested to match the structure of a device and this structure is then mirrored to the transport layer for the visibility of the user. -Create a `TemperatureRampController` with two `AttrRW`s the ramp start and end, update -the IO to include an optional suffix for the commands so that it can be shared with -the parent `TemperatureController` and add an argument to define how many ramps there -are, which is used to register the correct number of ramp controllers with the parent. +Create a `TemperatureRampController` with two `AttrRW`s for the ramp start and end, give +`TemperatureProtocol` an optional suffix so an instance can be shared with the parent +`TemperatureController` while still addressing an individual ramp, and add an argument +to define how many ramps there are, which is used to register the correct number of ramp +controllers with the parent. ::::{admonition} Code 10 :class: dropdown, hint :::{literalinclude} /snippets/static10.py -:emphasize-lines: 10,28,32,35,44,48-56,64,70-74,83 +:emphasize-lines: 30-53,57,73-77 ::: :::: @@ -313,7 +320,7 @@ Add an `AttrRW` to the `TemperatureRampController`s with an `Enum` type, using a :class: dropdown, hint :::{literalinclude} /snippets/static11.py -:emphasize-lines: 1,11,49-51,57 +:emphasize-lines: 1,31-33,48-53,67-71 ::: :::: @@ -355,39 +362,41 @@ The applied voltage for each ramp is also available with the `V?` command, but t is an array with each element corresponding to a ramp. Here it will be simplest to manually fetch the array in the parent controller and pass each value into ramp controller. This can be done with a `scan` method - these are called at a defined rate, -similar to the `update` method of an `AttributeIO`. +similar to how each attribute's getter is polled. -Add an `AttrR` for the voltage to the `TemperatureRampController`, but do not pass it an -IO ref. Then add a method to the `TemperatureController` with a `@scan` decorator that -gets the array of voltages and sets each ramp controller with its value. Also add -`AttrR`s for the target and actual temperature for each ramp as described above. +Add an `AttrR` for the voltage to the `TemperatureRampController`, but do not give it a +`getter` - it is a soft attribute, pushed to directly by the parent controller's scan +method instead. Then add a method to the `TemperatureController` with a `@scan` +decorator that gets the array of voltages and sets each ramp controller with its value. +Also add `AttrR`s for the target and actual temperature for each ramp as described +above. ::::{admonition} Code 12 :class: dropdown, hint :::{literalinclude} /snippets/static12.py -:emphasize-lines: 2,16,60-62,91-97 +:emphasize-lines: 11,56-58,78-82,123-129 ::: :::: Creating attributes is intended to be a simple API covering most use cases, but where more flexibility is needed wrapped controller methods can be useful to avoid adding -complexity to the IO to handle a small subset of attributes. It is also useful for -implementing higher level logic on top of the attributes that expose the API of a device -directly. For example, it would be useful to have a single button to stop all of the -ramps at the same time. This can be done with a `command` method. These are similar to -`scan` methods except that they create an API in transport layer in the same way an +complexity to a getter/setter to handle a small subset of attributes. It is also useful +for implementing higher level logic on top of the attributes that expose the API of a +device directly. For example, it would be useful to have a single button to stop all of +the ramps at the same time. This can be done with a `command` method. These are similar +to `scan` methods except that they create an API in transport layer in the same way an attribute does. Add a method with a `@command` decorator to set enabled to false in every ramp -controller. +controller by calling `set` on each `enabled` attribute. ::::{admonition} Code 13 :class: dropdown, hint :::{literalinclude} /snippets/static13.py -:emphasize-lines: 1,17,100-105 +:emphasize-lines: 1,132-137 ::: :::: @@ -412,14 +421,14 @@ application. To enable logging from the core framework call `configure_logging` arguments (the default logging level is INFO). To log messages from a driver, import the singleton `logger` directly. -Create a module-level logger to log status of the application start up. Create a class -logger for `TemperatureControllerAttributeIO` to log the commands it sends. +Create a module-level logger to log status of the application start up, and use it +inside `TemperatureProtocol.send_command` to log the commands it sends. ::::{admonition} Code 14 :class: dropdown, hint :::{literalinclude} /snippets/static14.py -:emphasize-lines: 13,48,110,115 +:emphasize-lines: 12,28,145,150 ::: :::: @@ -427,55 +436,48 @@ logger for `TemperatureControllerAttributeIO` to log the commands it sends. Try setting a PV and check the console for the log message it prints. ``` -[2025-11-18 11:26:41.065+0000 I] Sending attribute value [TemperatureControllerAttributeIO] command=E01=70, attribute=AttrRW(path=R1.end, datatype=Int, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='E')) +[2026-01-01 11:26:41.065+0000 I] Sending attribute value [fastcs] command=E01=70 ``` -A similar log message could be added for the update method of the IO, but this would be -very verbose. For this use case FastCS provides the `Tracer` class, which is inherited -by `AttributeIO`, among other core FastCS classes. This enables the logging of `TRACE` -level log messages that are disabled by default, but can be enabled at runtime. +A similar log message could be added for the getters, but this would be very verbose. +For this use case FastCS provides the `Tracer` class, which can be inherited by anything +that wants to support selective, per-instance logging - `Attribute` and `BaseController` +already do. This enables the logging of `TRACE` level log messages that are disabled by +default, but can be enabled at runtime. -Update the `send` method of the IO to log a message showing the query that was sent and -the response from the device. Update the `configure_logging` call to pass -`LogLevel.TRACE` as the log level, so that when tracing is enabled the messages are -visible. +Make `TemperatureProtocol` inherit `Tracer` too, and update `send_query` to take a +`topic` argument and log a message showing the query that was sent and the response +from the device via `self.log_event`, passing through the attribute doing the query as +the `topic`. Update each getter to pass its own attribute as `topic`. Update the +`configure_logging` call to pass `LogLevel.TRACE` as the log level, so that when tracing +is enabled the messages are visible. ::::{admonition} Code 15 :class: dropdown, hint :::{literalinclude} /snippets/static15.py -:emphasize-lines: 13,49-51,118 +:emphasize-lines: 12,14,21,34-36,41,125,153 ::: :::: Enable tracing on the `power` attribute by calling `enable_tracing` and then enable a -ramp so that the value updates. Check the console to see the messages. Call +ramp so that the value updates. Check the console to see the messages. Call `disable_tracing` to disable the log messages for `power`. ``` In [1]: controller.power.enable_tracing() -[2025-11-18 11:11:12.060+0000 T] Query for attribute [TemperatureControllerAttributeIO] query=P?, response=0.0 -[2025-11-18 11:11:12.060+0000 T] Attribute set [AttrR] attribute=AttrR(path=power, datatype=Float, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='P')), value=0.0 -[2025-11-18 11:11:12.060+0000 T] PV set from attribute [fastcs.transports.epics.ca.ioc] pv=DEMO:Power, value=0.0 -[2025-11-18 11:11:12.194+0000 I] PV put: DEMO:R1:Enabled = 1 [fastcs.transports.epics.ca.ioc] pv=DEMO:R1:Enabled, value=1 -[2025-11-18 11:11:12.195+0000 I] Sending attribute value [TemperatureControllerAttributeIO] command=N01=1, attribute=AttrRW(path=R1.enabled, datatype=Enum, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='N')) -[2025-11-18 11:11:12.261+0000 T] Update attribute [AttrR] -[2025-11-18 11:11:12.262+0000 T] Query for attribute [TemperatureControllerAttributeIO] query=P?, response=29.040181873093132 -[2025-11-18 11:11:12.262+0000 T] Attribute set [AttrR] attribute=AttrR(path=power, datatype=Float, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='P')), value=29.040181873093132 -[2025-11-18 11:11:12.262+0000 T] PV set from attribute [fastcs.transports.epics.ca.ioc] pv=DEMO:Power, value=29.04 -[2025-11-18 11:11:12.463+0000 T] Update attribute [AttrR] -[2025-11-18 11:11:12.464+0000 T] Query for attribute [TemperatureControllerAttributeIO] query=P?, response=30.452524641833854 -[2025-11-18 11:11:12.464+0000 T] Attribute set [AttrR] attribute=AttrR(path=power, datatype=Float, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='P')), value=30.452524641833854 -[2025-11-18 11:11:12.465+0000 T] PV set from attribute [fastcs.transports.epics.ca.ioc] pv=DEMO:Power, value=30.45 +[2026-01-01 11:11:12.060+0000 T] Query for attribute [fastcs] query=P?, response=0.0 +[2026-01-01 11:11:12.194+0000 I] PV put: DEMO:R1:Enabled = 1 [fastcs.transports.epics.ca.ioc] pv=DEMO:R1:Enabled, value=1 +[2026-01-01 11:11:12.195+0000 I] Sending attribute value [fastcs] command=N01=1 +[2026-01-01 11:11:12.262+0000 T] Query for attribute [fastcs] query=P?, response=29.040181873093132 +[2026-01-01 11:11:12.463+0000 T] Query for attribute [fastcs] query=P?, response=30.452524641833854 In [2]: controller.power.disable_tracing() ``` -These log messages include other trace loggers that log messages with `power` as the -`topic`, so they also appear automatically, so the log messages show changes to the -attribute throughout the stack: the query to the device and its response, the value the -attribute is set to, and the value that the PV in the EPICS CA transport is set to. - +Only messages with `power` as their topic appear, even though every attribute's getter +is querying the device on the same period - other attributes' queries stay silent until +tracing is enabled on them too. :::{note} The `Tracer` can also be used as a module-level instance for use in free functions. diff --git a/pyproject.toml b/pyproject.toml index f178a375c..95129fb4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,21 @@ addopts = """ """ # https://iscinumpy.gitlab.io/post/bound-version-constraints/#watch-for-warnings # https://github.com/DiamondLightSource/FastCS/issues/230 -filterwarnings = "error" +# +# The two pytest-generated warnings are downgraded to "report but do not fail". +# They are raised for events that happen *outside* any test - an exception during +# garbage collection, or in a non-main thread - and pytest attributes them to +# whichever test happens to be running at the time. This suite leaves objects +# alive in its subprocess and multiprocessing fixtures (run_ioc_as_subprocess's +# forkserver and Queues, the tickit Popen in test_docs_snippets), so the +# resulting ResourceWarning lands on an unrelated test, and which one varies by +# Python version and by run. They are still printed, so real leaks stay visible; +# track them down with PYTHONTRACEMALLOC=25, which adds the allocation traceback. +filterwarnings = [ + "error", + "default::pytest.PytestUnraisableExceptionWarning", + "default::pytest.PytestUnhandledThreadExceptionWarning", +] # Doctest python code in docs, python code in src docstrings, test functions in tests testpaths = "docs src tests" timeout = 5 diff --git a/src/fastcs/attributes/__init__.py b/src/fastcs/attributes/__init__.py index d0f5e59f0..80b848411 100644 --- a/src/fastcs/attributes/__init__.py +++ b/src/fastcs/attributes/__init__.py @@ -1,10 +1,18 @@ +from .attr_decorator import UnboundAttr as UnboundAttr +from .attr_decorator import UnboundAttrRW as UnboundAttrRW +from .attr_decorator import UnboundGetter as UnboundGetter +from .attr_decorator import UnboundSetter as UnboundSetter +from .attr_decorator import attr as attr from .attr_r import AttrR as AttrR +from .attr_r import Getter as Getter +from .attr_r import NotPolled as NotPolled +from .attr_r import Polled as Polled +from .attr_r import Schedule as Schedule from .attr_rw import AttrRW as AttrRW from .attr_w import AttrW as AttrW +from .attr_w import Setter as Setter from .attribute import Attribute as Attribute from .attribute import AttributeAccessMode as AttributeAccessMode -from .attribute_io import AnyAttributeIO as AnyAttributeIO -from .attribute_io import AttributeIO as AttributeIO -from .attribute_io_ref import AttributeIORef as AttributeIORef -from .attribute_io_ref import AttributeIORefT as AttributeIORefT from .hinted_attribute import HintedAttribute as HintedAttribute +from .severity import Severity as Severity +from .update import Update as Update diff --git a/src/fastcs/attributes/_infer_datatype.py b/src/fastcs/attributes/_infer_datatype.py new file mode 100644 index 000000000..35365c5ad --- /dev/null +++ b/src/fastcs/attributes/_infer_datatype.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any, get_args, get_origin + +from fastcs.attributes.update import Update +from fastcs.datatypes import resolve_datatype + + +def _unwrap_update_annotation(annotation: Any) -> Any: + if get_origin(annotation) is Update: + args = get_args(annotation) + return args[0] if args else annotation + return annotation + + +def _datatype_for_annotation(annotation: Any) -> Any | None: + """The annotation itself, if it is a datatype an attribute can hold. + + The datatype *is* the python type, so inference is just a check that the + annotation names one FastCS supports - including subscripted spellings + such as ``Array1D[np.int32]``. + """ + try: + resolve_datatype(annotation) + except TypeError: + return None + + return annotation + + +def infer_datatype_from_getter(getter: Callable) -> Any | None: + """Infer a datatype from a getter's return type annotation.""" + signature = inspect.signature(getter, eval_str=True) + annotation = signature.return_annotation + if annotation is inspect.Signature.empty: + return None + return _datatype_for_annotation(_unwrap_update_annotation(annotation)) + + +def infer_datatype_from_setter(setter: Callable) -> Any | None: + """Infer a datatype from a setter's value parameter annotation.""" + signature = inspect.signature(setter, eval_str=True) + parameters = list(signature.parameters.values()) + if not parameters: + return None + annotation = parameters[0].annotation + if annotation is inspect.Signature.empty: + return None + return _datatype_for_annotation(annotation) diff --git a/src/fastcs/attributes/attr_decorator.py b/src/fastcs/attributes/attr_decorator.py new file mode 100644 index 000000000..905e77501 --- /dev/null +++ b/src/fastcs/attributes/attr_decorator.py @@ -0,0 +1,356 @@ +"""``@attr`` decorator sugar over the getter/setter constructors (ADR 0018). + +``@attr`` is the one-decorated-getter spelling a PyTango user expects, written +over the same machinery as the procedural ``AttrR(getter=...)`` / +``AttrRW(getter=..., setter=...)`` form rather than beside it. It is a +decorator only - there is no free-function ``attr()`` factory, and no +``@attr_r``/``@attr_rw``: an ``AttrR`` is a decorated getter, an ``AttrRW`` is +that plus a ``@x.setter``, and a write-only ``AttrW`` is rare enough to write +longhand. + +Binding follows ``@command``/``@scan``: the class body holds an `UnboundAttr` +describing the attribute, and each controller instance gets a fresh +``AttrR``/``AttrRW`` built from it at construction time. Nothing is deepcopied +from a class-scope prototype, so two instances of a controller never share an +attribute. +""" + +from __future__ import annotations + +from asyncio import iscoroutinefunction +from collections.abc import Awaitable, Callable +from inspect import Parameter, Signature, getdoc, signature +from types import MethodType +from typing import Any, Generic, Unpack, cast, overload + +from fastcs.attributes._infer_datatype import ( + _datatype_for_annotation, + _unwrap_update_annotation, +) +from fastcs.attributes.attr_r import AttrR, NotPolled, Polled, Schedule +from fastcs.attributes.attr_rw import AttrRW +from fastcs.attributes.update import Update +from fastcs.datatypes import DType_T, Meta +from fastcs.util import Controller_T + +UnboundGetter = Callable[[Controller_T], Awaitable[DType_T | Update[DType_T]]] +"""An ``@attr`` getter, taking the `Controller` it will be bound to as ``self``""" +UnboundSetter = Callable[ + [Controller_T, DType_T], Awaitable[None | DType_T | Update[DType_T]] +] +"""An ``@x.setter`` setter, taking the `Controller` it will be bound to as ``self``""" + + +def _type_name(datatype: Any) -> str: + """A datatype as it was most likely written, to name it in an error.""" + return getattr(datatype, "__name__", None) or repr(datatype) + + +def _summary(docstring: str | None) -> str | None: + """The first paragraph of a docstring, as a single line. + + A description is the one-line label a transport shows next to the value, so + a longer docstring carries only its summary into one. + """ + if not docstring: + return None + + return " ".join(docstring.split("\n\n", 1)[0].split()) or None + + +def _method_signature(fn: Callable) -> Signature: + """Resolve the signature of an async ``@attr`` getter or setter. + + Args: + fn: The decorated function + + Returns: + The signature, with its annotations resolved + + Raises: + TypeError: If the function is not an async method + + """ + if not iscoroutinefunction(fn): + raise TypeError("must be an async function") + + return signature(fn, eval_str=True) + + +class UnboundAttr(Generic[Controller_T, DType_T]): + """An ``@attr``-decorated getter, and the metadata that goes with it. + + An instance of this class lives in the `Controller` class body, in place of + the method it decorates. It is a declaration rather than an attribute: each + `Controller` instance binds it into an ``AttrR`` of its own during + construction, so the getter is bound to that instance and nothing is shared + between instances. + + It is a (non-data) descriptor only so that the attribute reads as the + ``AttrR`` it becomes - ``self.voltage.readback`` rather than the + declaration. Once the controller has bound it the attribute is in the + instance dictionary, which a non-data descriptor does not intercept, so + ``__get__`` runs only before binding. + """ + + def __init__( + self, + getter: UnboundGetter[Controller_T, DType_T], + schedule: Schedule[DType_T] | None = None, + meta: Meta | None = None, + setter: UnboundSetter[Controller_T, DType_T] | None = None, + ) -> None: + try: + getter_signature = _method_signature(getter) + getter_parameters = list(getter_signature.parameters.values()) + if len(getter_parameters) != 1 or any( + parameter.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD) + for parameter in getter_parameters + ): + raise TypeError("must be a method taking self") + except TypeError as error: + raise TypeError(f"@attr getter {getter.__qualname__} {error}") from error + + annotation = _unwrap_update_annotation(getter_signature.return_annotation) + datatype = _datatype_for_annotation(annotation) + if datatype is None: + if annotation is not Signature.empty: + raise TypeError( + f"@attr getter {getter.__qualname__} must annotate a supported " + f"datatype, got {_type_name(annotation)}" + ) + raise TypeError( + f"@attr getter {getter.__qualname__} must annotate the datatype " + "the attribute holds as its return type, for example `-> float`" + ) + + if isinstance(schedule, Polled | NotPolled) and schedule.getter is not None: + raise TypeError( + f"The schedule given to @attr on {getter.__qualname__} already " + "has a getter; pass a bare Polled(period=...) or NotPolled()" + ) + + self._getter = getter + self._setter = setter + self._schedule = schedule + self._datatype = datatype + self._meta: dict[str, Any] = dict(meta or {}) + self._name = getter.__name__ + + def __set_name__(self, owner: type, name: str) -> None: + self._name = name + + @overload + def __get__( + self, instance: None, owner: type | None = None, / + ) -> UnboundAttr[Controller_T, DType_T]: ... + + @overload + def __get__( + self, instance: object, owner: type | None = None, / + ) -> AttrR[DType_T]: ... + + def __get__(self, instance: Any, owner: type | None = None, /) -> Any: + if instance is None: + return self + + raise AttributeError( + f"Attribute '{self._name}' does not exist yet. An @attr declaration " + "becomes an attribute when the controller is constructed, so it " + "cannot be reached before Controller.__init__ has run." + ) + + @property + def datatype(self) -> Any: + """The datatype inferred from the getter's return annotation.""" + return self._datatype + + def has_setter(self) -> bool: + return self._setter is not None + + def setter( + self, fn: UnboundSetter[Controller_T, DType_T] + ) -> UnboundAttrRW[Controller_T, DType_T]: + """Declare the writer half, making this an ``AttrRW``. + + Mirrors ``@property``/``@x.setter``, so a read-write attribute is one + name with two decorated methods:: + + @voltage.setter + async def voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") + + Args: + fn: The setter, taking ``self`` and the value to apply + + Returns: + A new `UnboundAttrRW` with the setter attached. This one is left + alone, so a subclass declaring a setter does not also give one to + the base class it inherited the getter from. + + Raises: + TypeError: If the setter is not an async method taking a value, or + annotates a value of a different datatype to the getter's + + """ + if self._setter is not None: + raise TypeError( + f"@attr getter {self._getter.__qualname__} already has a setter" + ) + + try: + setter_signature = _method_signature(fn) + setter_parameters = list(setter_signature.parameters.values()) + if len(setter_parameters) != 2 or any( + parameter.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD) + for parameter in setter_parameters + ): + raise TypeError("must be a method taking self and the value to set") + except TypeError as error: + raise TypeError(f"@attr setter {fn.__qualname__} {error}") from error + + value = list(setter_signature.parameters.values())[1] + if value.annotation is not Signature.empty: + if _datatype_for_annotation(value.annotation) is not self._datatype: + raise TypeError( + f"@attr setter {fn.__qualname__} takes a " + f"{_type_name(value.annotation)}, but its getter returns a " + f"{_type_name(self._datatype)}" + ) + + return UnboundAttrRW( + self._getter, + schedule=self._schedule, + meta=cast(Meta, self._meta), + setter=fn, + ) + + def bind(self, controller: Controller_T) -> AttrR[DType_T]: + """Build the attribute this declares, for one `Controller` instance. + + Args: + controller: The controller whose methods the getter and setter are + + Returns: + An ``AttrR``, or an ``AttrRW`` if a setter was declared + + """ + getter = MethodType(self._getter, controller) + scheduled = getter if self._schedule is None else self._schedule(getter) + + meta = dict(self._meta) + if "description" not in meta: + description = _summary(getdoc(self._getter)) + if description is not None: + meta["description"] = description + + if self._setter is None: + attribute = AttrR(self._datatype, getter=scheduled, **meta) + else: + attribute = AttrRW( + self._datatype, + getter=scheduled, + setter=MethodType(self._setter, controller), + **meta, + ) + + return cast(AttrR[DType_T], attribute) + + def __repr__(self) -> str: + access_mode = "rw" if self._setter is not None else "r" + return ( + f"{type(self).__name__}({self._getter.__qualname__}, " + f"access_mode={access_mode!r}, datatype={_type_name(self._datatype)})" + ) + + +class UnboundAttrRW(UnboundAttr[Controller_T, DType_T]): + """An `UnboundAttr` that has been given a setter, so it binds an ``AttrRW``. + + A separate class only so that a declaration carrying a setter reads as the + ``AttrRW`` it becomes, and one without it as an ``AttrR``. + """ + + @overload + def __get__( + self, instance: None, owner: type | None = None, / + ) -> UnboundAttrRW[Controller_T, DType_T]: ... + + @overload + def __get__( + self, instance: object, owner: type | None = None, / + ) -> AttrRW[DType_T]: ... + + def __get__(self, instance: Any, owner: type | None = None, /) -> Any: + return super().__get__(instance, owner) + + def bind(self, controller: Controller_T) -> AttrRW[DType_T]: + return cast(AttrRW[DType_T], super().bind(controller)) + + +@overload +def attr( + getter: UnboundGetter[Controller_T, DType_T], / +) -> UnboundAttr[Controller_T, DType_T]: ... + + +@overload +def attr( + schedule: Schedule[Any] | None = None, /, **meta: Unpack[Meta] +) -> Callable[ + [UnboundGetter[Controller_T, DType_T]], UnboundAttr[Controller_T, DType_T] +]: ... + + +def attr(getter_or_schedule: Any = None, /, **meta: Any) -> Any: + """Declare an `Attribute` from the method that reads it. + + The datatype is the getter's return annotation and the getter's docstring + is the attribute's description, so the common "one attribute, one device + call" case is a single decorated method:: + + class PowerSupply(Controller): + @attr(Polled(period=0.5), units="V") + async def voltage(self) -> float: + \"\"\"Output voltage.\"\"\" + return float(await self._conn.query("V?")) + + @voltage.setter + async def voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") + + The optional leading positional argument is a schedule - the same + `Polled`/`NotPolled` objects the procedural form wraps its getter in, so + the two spellings share one vocabulary: + + - ``@attr(units="V")`` is read once, when the controller connects, which is + what a bare ``getter=`` means and what a bare ``@attr`` means + - ``@attr(Polled(period=0.5))`` is read every 0.5 seconds, as + ``AttrR(getter=Polled(g, period=0.5))`` is + - ``@attr(NotPolled())`` is never read on a schedule, as + ``AttrR(getter=NotPolled(g))`` is + + Args: + getter_or_schedule: The getter, when used bare as ``@attr``; otherwise + a `Polled` or `NotPolled` schedule, or nothing + meta: Metadata for the attribute, checked against the datatype the + getter returns - ``precision`` on a ``str`` attribute raises + + Returns: + An `UnboundAttr`, which each `Controller` instance binds into an + attribute of its own + + """ + if getter_or_schedule is not None and not isinstance( + getter_or_schedule, Polled | NotPolled + ): + # Bare ``@attr``, so what we have is the getter itself. There is no way + # to pass metadata in that form, so there is none to carry over. + return UnboundAttr(getter_or_schedule) + + def wrapper( + getter: UnboundGetter[Controller_T, DType_T], + ) -> UnboundAttr[Controller_T, DType_T]: + return UnboundAttr(getter, schedule=getter_or_schedule, meta=cast(Meta, meta)) + + return wrapper diff --git a/src/fastcs/attributes/attr_r.py b/src/fastcs/attributes/attr_r.py index 28d66c07b..b36b6c3a5 100644 --- a/src/fastcs/attributes/attr_r.py +++ b/src/fastcs/attributes/attr_r.py @@ -1,141 +1,342 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from typing import Any +import time +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import KW_ONLY, dataclass, replace +from typing import Any, Generic, Unpack, overload +from fastcs.attributes._infer_datatype import infer_datatype_from_getter from fastcs.attributes.attribute import Attribute, AttributeAccessMode -from fastcs.attributes.attribute_io_ref import AttributeIORefT +from fastcs.attributes.severity import Severity +from fastcs.attributes.update import Update from fastcs.attributes.util import AttrValuePredicate, PredicateEvent -from fastcs.datatypes import DataType, DType_T +from fastcs.datatypes import ( + Array1DMeta, + Array_T, + BoolMeta, + DType_T, + Enum_T, + EnumMeta, + FloatMeta, + Inferred_T, + IntMeta, + Meta, + StrMeta, + Table, + TableMeta, +) from fastcs.logging import logger +from fastcs.util import ONCE -AttrIOUpdateCallback = Callable[["AttrR[DType_T, Any]"], Coroutine[None, None, None]] -"""An AttributeIO callback that takes an AttrR and updates its value""" -AttrUpdateCallback = Callable[[], Coroutine[None, None, None]] -"""A callback to be called periodically to update an attribute""" -AttrOnUpdateCallback = Callable[[DType_T], Coroutine[None, None, None]] -"""A callback to be called when the value of the attribute is updated""" +Getter = Callable[[], Awaitable[DType_T | Update[DType_T]]] +"""A callable that fetches a fresh value for an attribute from its source""" +AttrReadbackCallback = Callable[[DType_T], Coroutine[None, None, None]] +"""A callback to be called when the readback of the attribute updates""" -class AttrR(Attribute[DType_T, AttributeIORefT]): +@dataclass +class Polled(Generic[DType_T]): + """A getter to be read repeatedly, every ``period`` seconds:: + + AttrR(getter=Polled(protocol.get_temperature, period=0.1)) + + Use this for values the device changes on its own, such as readings and status. + A getter passed without a schedule is read once, when the controller connects. + """ + + getter: Getter[DType_T] | None = None + _: KW_ONLY + period: float + + def __call__(self, getter: Getter[DType_T]) -> Polled[DType_T]: + """Bind a getter, so a schedule can also be applied as a decorator.""" + return replace(self, getter=getter) + + +@dataclass +class NotPolled(Generic[DType_T]): + """A getter that is never read on a schedule:: + + AttrR(getter=NotPolled(protocol.get_label)) + + The value is only set explicitly - from a ``@scan`` or a subscription calling + ``attr.update()`` - or read on demand with ``await attr.poll()``. This is not the + same as an attribute with no getter at all, which has nothing to read. + """ + + getter: Getter[DType_T] | None = None + + def __call__(self, getter: Getter[DType_T]) -> NotPolled[DType_T]: + """Bind a getter, so a schedule can also be applied as a decorator.""" + return replace(self, getter=getter) + + +Schedule = Polled[DType_T] | NotPolled[DType_T] +"""A getter with a reading schedule attached""" + + +class AttrR(Attribute[DType_T]): """A read-only ``Attribute``""" + # One overload per datatype, so that metadata a datatype has no use for is + # a type error rather than a field silently ignored: ``AttrR(str, + # precision=3)`` does not type check. The last overload is the + # inferred-datatype case, where the datatype is only known from the + # getter/setter annotation, so the metadata is checked at runtime. + # + # Overload resolution takes the first datatype a call matches, and ``bool`` + # matches ``int`` while ``int`` matches ``float``. So ``AttrR(bool, + # units=...)`` resolves to the ``int`` overload rather than failing here - + # the constructor's runtime check is what rejects it. A call whose metadata + # is valid always picks its own datatype's overload. + @overload + def __init__( + self: AttrR[bool], + datatype: type[bool], + getter: Getter[bool] | Schedule[bool] | None = None, + initial_value: bool | None = None, + **meta: Unpack[BoolMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[int], + datatype: type[int], + getter: Getter[int] | Schedule[int] | None = None, + initial_value: int | None = None, + **meta: Unpack[IntMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[float], + datatype: type[float], + getter: Getter[float] | Schedule[float] | None = None, + initial_value: float | None = None, + **meta: Unpack[FloatMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[str], + datatype: type[str], + getter: Getter[str] | Schedule[str] | None = None, + initial_value: str | None = None, + **meta: Unpack[StrMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[Enum_T], + datatype: type[Enum_T], + getter: Getter[Enum_T] | Schedule[Enum_T] | None = None, + initial_value: Enum_T | None = None, + **meta: Unpack[EnumMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[Table], + datatype: type[Table], + getter: Getter[Table] | Schedule[Table] | None = None, + initial_value: Table | None = None, + **meta: Unpack[TableMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[Array_T], + datatype: type[Array_T], + getter: Getter[Array_T] | Schedule[Array_T] | None = None, + initial_value: Array_T | None = None, + **meta: Unpack[Array1DMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[Inferred_T], + datatype: None = None, + getter: Getter[Inferred_T] | Schedule[Inferred_T] | None = None, + initial_value: Inferred_T | None = None, + **meta: Unpack[Meta], + ) -> None: ... + def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, - initial_value: DType_T | None = None, - description: str | None = None, + datatype: Any = None, + getter: Any = None, + initial_value: Any = None, + **meta: Any, ) -> None: - super().__init__(datatype, io_ref, group, description=description) + match getter: + case Polled() | NotPolled(): + if getter.getter is None: + raise ValueError( + f"{type(getter).__name__} was given no getter to schedule" + ) + resolved_getter = getter.getter + poll_period = getter.period if isinstance(getter, Polled) else None + case None: + resolved_getter, poll_period = None, None + case _: + # A getter with no schedule is read once, when the controller + # connects - the safe default, and what a bare ``@attr`` means. + resolved_getter, poll_period = getter, ONCE + + if datatype is None and resolved_getter is not None: + datatype = infer_datatype_from_getter(resolved_getter) + + # Pass the datatype on rather than validating it here: in an ``AttrRW`` the + # setter may still supply it, and ``Attribute`` makes the final check. + super().__init__(datatype, **meta) + self._value: DType_T = ( - datatype.initial_value if initial_value is None else initial_value + self.default_value() if initial_value is None else initial_value ) - self._update_callback: AttrIOUpdateCallback[DType_T] | None = None - """Callback to update the value of the attribute with an IO to the source""" - self._on_update_callbacks: ( - list[tuple[AttrOnUpdateCallback[DType_T], bool]] | None + self._timestamp: float = time.time() + """When the cached value was obtained, or when the attribute was created""" + self._severity: Severity = Severity.NO_ALARM + """How wrong the cached value is, as last reported""" + self._getter = resolved_getter + self._getter: Getter[DType_T] | None = resolved_getter + self._poll_period: float | None = poll_period + """Period in seconds between calls to poll(), or ONCE, or None (on-demand)""" + self._readback_callbacks: ( + list[tuple[AttrReadbackCallback[DType_T], bool]] | None ) = None - """Callbacks to publish changes to the value of the attribute""" + """Callbacks to publish changes to the readback of the attribute""" self._on_update_events: set[PredicateEvent[DType_T]] = set() """Events to set when the value satisifies some predicate""" - def get(self) -> DType_T: - """Get the cached value of the attribute.""" + @property + def readback(self) -> DType_T: + """The last known value of the attribute.""" return self._value + @property + def timestamp(self) -> float: + """When the last known value was obtained, as a unix timestamp. + + The time the source reported, if it reported one, and otherwise the + time the update reached FastCS. + """ + return self._timestamp + + @property + def severity(self) -> Severity: + """How wrong the last known value is, as the source last reported.""" + return self._severity + + def has_getter(self) -> bool: + return self._getter is not None + + @property + def poll_period(self) -> float | None: + return self._poll_period + @property def access_mode(self) -> AttributeAccessMode: return "r" - async def update(self, value: Any) -> None: - """Update the value of the attibute + async def update(self, value: DType_T | Update[DType_T]) -> None: + """Update the value of the attribute This sets the cached value of the attribute presented in the API. It should - generally only be called from an IO or a controller that is updating the value - from some underlying source. + generally only be called from a getter or a controller that is updating the + value from some underlying source. Any update callbacks will be called with the new value and any update events with predicates satisfied by the new value will be set. - To request a change to the setpoint of the attribute, use the ``put`` method, + To request a change to the setpoint of the attribute, use the ``set`` method, which will attempt to apply the change to the underlying source. + A value that arrives as an ``Update`` may carry the time it was obtained + and how wrong it is; a bare value is stamped with the time it arrived and + reported as ``Severity.NO_ALARM``. + Args: - value: The new value of the attribute + value: The new value of the attribute, or an ``Update`` wrapping it Raises: ValueError: If the value fails to be validated to DType_T """ + received_at = time.time() + if isinstance(value, Update): + timestamp = received_at if value.timestamp is None else value.timestamp + severity = Severity.NO_ALARM if value.severity is None else value.severity + value = value.readback + else: + timestamp, severity = received_at, Severity.NO_ALARM + self.log_event("Attribute set", value=repr(value), attribute=self) _previous_value = self._value try: - self._value = self._datatype.validate(value) + self._value = self.validate(value) except ValueError: logger.error("Failed to validate value", value=repr(value), attribute=self) raise + # Only once the value is known good, so a rejected update leaves the + # cached value and the time it was obtained agreeing with each other. + self._timestamp, self._severity = timestamp, severity + self.log_event("Value validated", value=repr(self._value), attribute=self) self._on_update_events -= { e for e in self._on_update_events if e.set(self._value) } - if self._on_update_callbacks is not None: - callbacks_to_call: list[AttrOnUpdateCallback[DType_T]] = [ + if self._readback_callbacks is not None: + callbacks_to_call: list[AttrReadbackCallback[DType_T]] = [ cb - for cb, always in self._on_update_callbacks - if always or not self.datatype.equal(self._value, _previous_value) + for cb, always in self._readback_callbacks + if always or not self.equal(self._value, _previous_value) ] try: await asyncio.gather(*[cb(self._value) for cb in callbacks_to_call]) except Exception as e: logger.opt(exception=e).error( - "On update callbacks failed", + "Readback callbacks failed", attribute=self, value=repr(self._value), ) raise - def add_on_update_callback( - self, callback: AttrOnUpdateCallback[DType_T], always: bool = False - ) -> None: - """Add a callback to be called when the value of the attribute is updated + async def poll(self) -> DType_T: + """Fetch a fresh value from the getter, cache it, and return it.""" + if self._getter is None: + raise RuntimeError(f"{self} has no getter") - The callback will be called with the updated value. + self.log_event("Poll attribute", topic=self) + result = await self._getter() + await self.update(result) + return self._value - """ - if self._on_update_callbacks is None: - self._on_update_callbacks = [] - self._on_update_callbacks.append((callback, always)) + def add_readback_callback( + self, callback: AttrReadbackCallback[DType_T], always: bool = False + ) -> None: + """Add a callback to be called when the readback of the attribute updates - def set_update_callback(self, callback: AttrIOUpdateCallback[DType_T]): - """Set the callback to update the value of the attribute from the source + The callback will be called with the updated readback value. Transports + should use this to publish the attribute's readback, and + ``AttrW.add_setpoint_callback`` to publish its setpoint. - The callback will be converted to an async task and called periodically. + Args: + callback: The callback to call with the updated readback value + always: Whether to call the callback on every ``update``, rather than + only when the new value differs from the cached one. Defaults to + ``False``, so an update that does not change the value is not + published. Pass ``True`` for a callback that must see every update + - one that timestamps it, or counts it, rather than displaying it. """ - if self._update_callback is not None: - raise RuntimeError("Attribute already has an IO update callback") - - self._update_callback = callback - - def bind_update_callback(self) -> AttrUpdateCallback: - """Bind self into the registered IO update callback""" - if self._update_callback is None: - raise RuntimeError("Attribute has no update callback") - else: - update_callback = self._update_callback - - async def update_attribute(): - self.log_event("Update attribute", topic=self) - await update_callback(self) - - return update_attribute + if self._readback_callbacks is None: + self._readback_callbacks = [] + self._readback_callbacks.append((callback, always)) async def wait_for_predicate( self, predicate: AttrValuePredicate[DType_T], *, timeout: float diff --git a/src/fastcs/attributes/attr_rw.py b/src/fastcs/attributes/attr_rw.py index 5f0c2edbd..90222c781 100644 --- a/src/fastcs/attributes/attr_rw.py +++ b/src/fastcs/attributes/attr_rw.py @@ -1,42 +1,191 @@ -from fastcs.attributes.attr_r import AttrR -from fastcs.attributes.attr_w import AttrW +from __future__ import annotations + +from typing import Any, Unpack, overload + +from fastcs.attributes.attr_r import AttrR, Getter, Schedule +from fastcs.attributes.attr_w import AttrW, Setter from fastcs.attributes.attribute import AttributeAccessMode -from fastcs.attributes.attribute_io_ref import AttributeIORefT -from fastcs.datatypes import DataType, DType_T +from fastcs.attributes.update import Update +from fastcs.datatypes import ( + Array1DMeta, + Array_T, + BoolMeta, + DType_T, + Enum_T, + EnumMeta, + FloatMeta, + Inferred_T, + IntMeta, + Meta, + StrMeta, + Table, + TableMeta, +) +from fastcs.logging import logger -class AttrRW(AttrR[DType_T, AttributeIORefT], AttrW[DType_T, AttributeIORefT]): +class AttrRW(AttrR[DType_T], AttrW[DType_T]): """A read-write ``Attribute``.""" + # One overload per datatype, so that metadata a datatype has no use for is + # a type error rather than a field silently ignored: ``AttrRW(str, + # precision=3)`` does not type check. The last overload is the + # inferred-datatype case, where the datatype is only known from the + # getter/setter annotation, so the metadata is checked at runtime. + # + # Overload resolution takes the first datatype a call matches, and ``bool`` + # matches ``int`` while ``int`` matches ``float``. So ``AttrRW(bool, + # units=...)`` resolves to the ``int`` overload rather than failing here - + # the constructor's runtime check is what rejects it. A call whose metadata + # is valid always picks its own datatype's overload. + @overload def __init__( - self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, - initial_value: DType_T | None = None, - description: str | None = None, - ): - super().__init__(datatype, io_ref, group, initial_value, description) + self: AttrRW[bool], + datatype: type[bool], + getter: Getter[bool] | Schedule[bool] | None = None, + setter: Setter[bool] | None = None, + initial_value: bool | None = None, + **meta: Unpack[BoolMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[int], + datatype: type[int], + getter: Getter[int] | Schedule[int] | None = None, + setter: Setter[int] | None = None, + initial_value: int | None = None, + **meta: Unpack[IntMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[float], + datatype: type[float], + getter: Getter[float] | Schedule[float] | None = None, + setter: Setter[float] | None = None, + initial_value: float | None = None, + **meta: Unpack[FloatMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[str], + datatype: type[str], + getter: Getter[str] | Schedule[str] | None = None, + setter: Setter[str] | None = None, + initial_value: str | None = None, + **meta: Unpack[StrMeta], + ) -> None: ... - self._setpoint_initialised = False + @overload + def __init__( + self: AttrRW[Enum_T], + datatype: type[Enum_T], + getter: Getter[Enum_T] | Schedule[Enum_T] | None = None, + setter: Setter[Enum_T] | None = None, + initial_value: Enum_T | None = None, + **meta: Unpack[EnumMeta], + ) -> None: ... - if io_ref is None: - self.set_on_put_callback(self._internal_update) + @overload + def __init__( + self: AttrRW[Table], + datatype: type[Table], + getter: Getter[Table] | Schedule[Table] | None = None, + setter: Setter[Table] | None = None, + initial_value: Table | None = None, + **meta: Unpack[TableMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[Array_T], + datatype: type[Array_T], + getter: Getter[Array_T] | Schedule[Array_T] | None = None, + setter: Setter[Array_T] | None = None, + initial_value: Array_T | None = None, + **meta: Unpack[Array1DMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[Inferred_T], + datatype: None = None, + getter: Getter[Inferred_T] | Schedule[Inferred_T] | None = None, + setter: Setter[Inferred_T] | None = None, + initial_value: Inferred_T | None = None, + **meta: Unpack[Meta], + ) -> None: ... + + def __init__( + self, + datatype: Any = None, + getter: Any = None, + setter: Any = None, + initial_value: Any = None, + **meta: Any, + ): + # There is no datatype handling to do here. ``AttrR`` infers it from the + # getter and ``AttrW`` from the setter; the MRO runs both in turn, so + # whichever can resolve it does, and ``Attribute`` makes the final check. + # ``setter`` travels through ``AttrR`` to ``AttrW`` the same way, which + # the public overloads - describing what a caller may pass - do not show. + super().__init__( + datatype, + getter=getter, + setter=setter, # pyright: ignore[reportCallIssue] + initial_value=initial_value, + **meta, + ) @property def access_mode(self) -> AttributeAccessMode: return "rw" - async def _internal_update( - self, attr: AttrW[DType_T, AttributeIORefT], value: DType_T - ): - """Update value directly when Attribute has no IO""" - assert attr is self - await self.update(value) + async def update(self, value: DType_T | Update[DType_T]) -> None: + """Update the readback of the attribute, and its setpoint if appropriate. - async def update(self, value: DType_T): + An ``Update`` carrying a ``setpoint`` publishes that too - the mechanism for + a device that reports its own setpoint. Otherwise, the first readback to + arrive establishes the setpoint, so that a setpoint display shows the + device's value rather than the datatype's default until first written. + + """ await super().update(value) - if not self._setpoint_initialised: - await self._call_sync_setpoint_callbacks(self._value) - self._setpoint_initialised = True + if isinstance(value, Update) and value.setpoint is not None: + await self.update_setpoint(value.setpoint) + elif not self._setpoint_known: + await self.update_setpoint(self._value) + + async def set(self, value: DType_T) -> None: + """Request a new value for the attribute. + + With no setter, this is a soft attribute: the requested value is pushed + straight to the readback. With a setter, a returned value is treated as the + device's accepted/clamped value and is applied to the readback as well as + the setpoint. + + """ + await self.update_setpoint(value) + + if self._setter is None: + await self.update(self._setpoint) + else: + try: + result = await self._setter(self._setpoint) + except Exception as e: + logger.opt(exception=e).error( + "Set failed", attribute=self, setpoint=self._setpoint + ) + else: + if isinstance(result, Update): + await self.update(result) + elif result is not None: + # A bare value is the device's accepted/clamped value - both the + # new readback and what it understood us to ask for. + await self.update_setpoint(result) + await self.update(result) + + self.log_event("Set complete", setpoint=self._setpoint, attribute=self) diff --git a/src/fastcs/attributes/attr_w.py b/src/fastcs/attributes/attr_w.py index 3e6a4517d..ca7e06c85 100644 --- a/src/fastcs/attributes/attr_w.py +++ b/src/fastcs/attributes/attr_w.py @@ -1,98 +1,218 @@ +from __future__ import annotations + import asyncio -from collections.abc import Awaitable, Callable -from typing import Any +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any, Unpack, overload +from fastcs.attributes._infer_datatype import infer_datatype_from_setter from fastcs.attributes.attribute import Attribute, AttributeAccessMode -from fastcs.attributes.attribute_io_ref import AttributeIORefT -from fastcs.datatypes import DataType, DType_T +from fastcs.attributes.update import Update +from fastcs.datatypes import ( + Array1DMeta, + Array_T, + BoolMeta, + DType_T, + Enum_T, + EnumMeta, + FloatMeta, + Inferred_T, + IntMeta, + Meta, + StrMeta, + Table, + TableMeta, +) from fastcs.logging import logger -AttrOnPutCallback = Callable[["AttrW[DType_T, Any]", DType_T], Awaitable[None]] -"""Callbacks to be called when the setpoint of an attribute is changed""" -AttrSyncSetpointCallback = Callable[[DType_T], Awaitable[None]] -"""Callbacks to be called when the setpoint of an attribute is changed""" +Setter = Callable[[DType_T], Awaitable[None | DType_T | Update[DType_T]]] +"""A callable that applies a new setpoint to an attribute's source""" +AttrSetpointCallback = Callable[[DType_T], Coroutine[None, None, None]] +"""A callback to be called when the setpoint of the attribute updates""" -class AttrW(Attribute[DType_T, AttributeIORefT]): +class AttrW(Attribute[DType_T]): """A write-only ``Attribute``.""" + # One overload per datatype, so that metadata a datatype has no use for is + # a type error rather than a field silently ignored: ``AttrW(str, + # precision=3)`` does not type check. The last overload is the + # inferred-datatype case, where the datatype is only known from the + # getter/setter annotation, so the metadata is checked at runtime. + # + # Overload resolution takes the first datatype a call matches, and ``bool`` + # matches ``int`` while ``int`` matches ``float``. So ``AttrW(bool, + # units=...)`` resolves to the ``int`` overload rather than failing here - + # the constructor's runtime check is what rejects it. A call whose metadata + # is valid always picks its own datatype's overload. + @overload + def __init__( + self: AttrW[bool], + datatype: type[bool], + setter: Setter[bool] | None = None, + **meta: Unpack[BoolMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[int], + datatype: type[int], + setter: Setter[int] | None = None, + **meta: Unpack[IntMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[float], + datatype: type[float], + setter: Setter[float] | None = None, + **meta: Unpack[FloatMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[str], + datatype: type[str], + setter: Setter[str] | None = None, + **meta: Unpack[StrMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[Enum_T], + datatype: type[Enum_T], + setter: Setter[Enum_T] | None = None, + **meta: Unpack[EnumMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[Table], + datatype: type[Table], + setter: Setter[Table] | None = None, + **meta: Unpack[TableMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[Array_T], + datatype: type[Array_T], + setter: Setter[Array_T] | None = None, + **meta: Unpack[Array1DMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[Inferred_T], + datatype: None = None, + setter: Setter[Inferred_T] | None = None, + **meta: Unpack[Meta], + ) -> None: ... + def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, - description: str | None = None, + datatype: Any = None, + setter: Any = None, + **meta: Any, ) -> None: - super().__init__( - datatype, # type: ignore - io_ref, - group, - description=description, - ) - self._on_put_callback: AttrOnPutCallback[DType_T] | None = None - """Callback to action a change to the setpoint of the attribute""" - self._sync_setpoint_callbacks: list[AttrSyncSetpointCallback[DType_T]] = [] + if datatype is None and setter is not None: + datatype = infer_datatype_from_setter(setter) + + super().__init__(datatype, **meta) + + self._setter: Setter[DType_T] | None = setter + self._setpoint: DType_T = self.default_value() + self._setpoint_known = False + """Whether the setpoint reflects a real value rather than the datatype default + + Until something establishes it - a write, or (for an ``AttrRW``) the first + readback - the setpoint is just the datatype's default and means nothing. + """ + self._setpoint_callbacks: list[AttrSetpointCallback[DType_T]] = [] """Callbacks to publish changes to the setpoint of the attribute""" + @property + def setpoint(self) -> DType_T: + """The last-requested value of the attribute.""" + return self._setpoint + + def has_setter(self) -> bool: + return self._setter is not None + @property def access_mode(self) -> AttributeAccessMode: return "w" - async def put(self, setpoint: DType_T, sync_setpoint: bool = False) -> None: - """Set the setpoint of the attribute + def add_setpoint_callback(self, callback: AttrSetpointCallback[DType_T]) -> None: + """Add a callback to be called when the setpoint of the attribute updates + + The callback will be called with the updated setpoint. Transports should + use this to publish the attribute's setpoint rather than tracking their own, + so that every transport agrees on it however the change was made. + + """ + self._setpoint_callbacks.append(callback) - This should be called by clients to the attribute such as transports to apply a - change to the attribute. The ``_on_put_callback`` will be called with this new - setpoint, which may or may not take effect depending on the validity of the new - value. For example, if the attribute has an IO to some device, the value might - be rejected. + async def update_setpoint(self, value: DType_T) -> None: + """Cache a new setpoint and publish it to the setpoint callbacks. - To directly change the value of the attribute, for example from an update loop - that has read a new value from some underlying source, call `AttrR.update`. + This does no IO - it is the setpoint-side counterpart of ``AttrR.update``. """ - setpoint = self._datatype.validate(setpoint) - if self._on_put_callback is not None: + self._setpoint = self.validate(value) + self._setpoint_known = True + + if self._setpoint_callbacks: try: - await self._on_put_callback(self, setpoint) + await asyncio.gather( + *[cb(self._setpoint) for cb in self._setpoint_callbacks] + ) except Exception as e: logger.opt(exception=e).error( - "Put failed", attribute=self, setpoint=setpoint + "Setpoint callbacks failed", + attribute=self, + setpoint=repr(self._setpoint), ) + raise + + def _setter_result_setpoint( + self, result: DType_T | Update[DType_T] + ) -> DType_T | None: + """The setpoint a setter's return value asks for, if any.""" + if isinstance(result, Update): + # A bare readback with no setpoint leaves the cached setpoint alone. + return result.setpoint + # A bare value is the device's accepted/clamped value - both what it will + # report and what it understood us to ask for. + return result + + async def set(self, value: DType_T) -> None: + """Request a new value for the attribute + + This should be called by clients to the attribute such as transports to apply + a change to the attribute. ``value`` is cached as the setpoint, then the + setter (if any) is called to apply it to the underlying source - the value + might be rejected or clamped, depending on the validity of the new value. If + the setter returns a value, that is treated as the source's accepted/clamped + value and becomes the new cached setpoint. + + To directly change the readback of an attribute, for example from an update + loop that has read a new value from some underlying source, call + ``AttrR.update``. - if sync_setpoint: + """ + await self.update_setpoint(value) + + if self._setter is not None: try: - await self._call_sync_setpoint_callbacks(setpoint) + result = await self._setter(self._setpoint) except Exception as e: logger.opt(exception=e).error( - "Sync setpoint failed", attribute=self, setpoint=setpoint + "Set failed", attribute=self, setpoint=self._setpoint ) + else: + if result is not None: + accepted = self._setter_result_setpoint(result) + if accepted is not None: + await self.update_setpoint(accepted) - self.log_event("Put complete", setpoint=setpoint, attribute=self) - - async def _call_sync_setpoint_callbacks(self, setpoint: DType_T) -> None: - if self._sync_setpoint_callbacks: - await asyncio.gather( - *[cb(setpoint) for cb in self._sync_setpoint_callbacks] - ) - - def set_on_put_callback(self, callback: AttrOnPutCallback[DType_T]) -> None: - """Set the callback to call when the setpoint is changed - - The callback will be called with the attribute and the new setpoint. - - """ - if self._on_put_callback is not None: - raise RuntimeError("Attribute already has an on put callback") - - self._on_put_callback = callback - - def add_sync_setpoint_callback( - self, callback: AttrSyncSetpointCallback[DType_T] - ) -> None: - """Add a callback to publish changes to the setpoint of the attribute - - The callback will be called with the new setpoint. - - """ - self._sync_setpoint_callbacks.append(callback) + self.log_event("Set complete", setpoint=self._setpoint, attribute=self) diff --git a/src/fastcs/attributes/attribute.py b/src/fastcs/attributes/attribute.py index ca4955b53..3b54d8b30 100644 --- a/src/fastcs/attributes/attribute.py +++ b/src/fastcs/attributes/attribute.py @@ -1,66 +1,82 @@ from abc import ABC, abstractmethod from collections.abc import Callable -from typing import Generic, Literal - -from fastcs.attributes.attribute_io_ref import AttributeIORefT -from fastcs.datatypes import DataType, DType, DType_T +from typing import Any, Generic, Literal, cast + +from fastcs.datatypes import ( + DType_T, + Meta, + Table, + default_value, + resolve_datatype, + validate_meta, + validate_value, + values_equal, +) from fastcs.tracer import Tracer AttributeAccessMode = Literal["r", "w", "rw"] -class Attribute(Generic[DType_T, AttributeIORefT], Tracer, ABC): +class Attribute(Generic[DType_T], Tracer, ABC): """Base FastCS attribute. Instances of this class added to a ``Controller`` will be used by the FastCS class. + + An attribute's datatype is a python type - ``float``, an `enum.Enum` + subclass, ``Array1D[np.int32]`` - and everything else that describes it + (precision, units, limits, shape) is metadata, held as a `Meta` typed dict + on ``attr.meta``. """ def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, - description: str | None = None, + datatype: Any = None, + **meta: Any, ) -> None: super().__init__() - assert issubclass(datatype.dtype, DType), ( - f"Attr type must be one of {DType}, received type {datatype.dtype}" - ) - self._io_ref = io_ref - self._datatype: DataType[DType_T] = datatype - self._group = group + # Subclasses may infer the datatype from a getter's return annotation or a + # setter's value annotation and pass the result down; by the time it reaches + # here it must be resolved. + if datatype is None: + raise ValueError( + "datatype must be given explicitly, or be inferable from the " + "getter's return annotation or the setter's value annotation" + ) + + dtype, element_type = resolve_datatype(datatype) + self._meta: Meta = _resolve_meta(datatype, element_type, meta) + self._dtype: type[DType_T] = dtype # pyright: ignore[reportAttributeAccessIssue] + + validate_meta(dtype, self._meta) + self.enabled = True - self.description = description - # A callback to use when setting the datatype to a different value, for example - # changing the units on an int. - self._update_datatype_callbacks: list[Callable[[DataType[DType_T]], None]] = [] + # A callback to use when setting the metadata to a different value, for + # example changing the units on an int. + self._update_meta_callbacks: list[Callable[[Meta], None]] = [] # Path and name to be filled in by Controller it is bound to self._name = "" self._path = [] @property - def io_ref(self) -> AttributeIORefT: - if self._io_ref is None: - raise RuntimeError(f"{self} has no AttributeIORef") - return self._io_ref - - def has_io_ref(self): - return self._io_ref is not None + def dtype(self) -> type[DType_T]: + """The python type this attribute holds.""" + return self._dtype @property - def datatype(self) -> DataType[DType_T]: - return self._datatype + def meta(self) -> Meta: + """Everything known about this attribute beyond its python type.""" + return self._meta @property - def dtype(self) -> type[DType_T]: - return self._datatype.dtype + def description(self) -> str | None: + return self._meta.get("description") @property def group(self) -> str | None: - return self._group + return self._meta.get("group") @property def name(self) -> str: @@ -80,19 +96,47 @@ def access_mode(self) -> AttributeAccessMode: """The access mode of this attribute.""" ... - def add_update_datatype_callback( - self, callback: Callable[[DataType[DType_T]], None] - ) -> None: - self._update_datatype_callbacks.append(callback) + def validate(self, value: Any) -> DType_T: + """Coerce a value to this attribute's datatype and check its metadata. - def update_datatype(self, datatype: DataType[DType_T]) -> None: - if not isinstance(self._datatype, type(datatype)): - raise ValueError( - f"Attribute datatype must be of type {type(self._datatype)}" - ) - self._datatype = datatype - for callback in self._update_datatype_callbacks: - callback(datatype) + Args: + value: The value to validate + + Returns: + The validated value + + Raises: + ValueError: If the value cannot be coerced, or breaks the metadata + + """ + return validate_value(self._dtype, self._meta, value) + + def equal(self, value1: DType_T, value2: DType_T) -> bool: + """Whether two values of this attribute's datatype are equal.""" + return values_equal(self._dtype, value1, value2) + + def default_value(self) -> DType_T: + """The value this attribute holds before anything has set one.""" + return default_value(self._dtype, self._meta) + + def add_update_meta_callback(self, callback: Callable[[Meta], None]) -> None: + self._update_meta_callbacks.append(callback) + + def update_meta(self, meta: Meta) -> None: + """Replace this attribute's metadata, notifying anything serving it. + + Args: + meta: The new metadata, which must be valid for the datatype + + Raises: + TypeError: If a field is not meaningful for the datatype + + """ + validate_meta(self._dtype, meta, self.full_name or "attribute") + + self._meta = meta + for callback in self._update_meta_callbacks: + callback(meta) def set_name(self, name: str): if self._name: @@ -113,6 +157,38 @@ def set_path(self, path: list[str]): def __repr__(self): name = self.__class__.__name__ full_name = self.full_name or None - datatype = self._datatype.__class__.__name__ - return f"{name}(name={full_name}, datatype={datatype}, io_ref={self._io_ref})" + return f"{name}(name={full_name}, dtype={self._dtype.__name__})" + + +def _resolve_meta( + datatype: Any, + element_type: Any, + meta: dict[str, Any], +) -> Meta: + """Fold what the datatype spelling implied into the metadata given.""" + resolved: dict[str, Any] = {k: v for k, v in meta.items() if v is not None} + + spelled_as_table = isinstance(datatype, type) and issubclass(datatype, Table) + if spelled_as_table and "structured_dtype" not in resolved: + raise TypeError( + "A Table attribute needs its columns - pass " + "structured_dtype=[('name', np.int32), ...]" + ) + if not spelled_as_table and "structured_dtype" in resolved: + raise TypeError( + "structured_dtype is only valid for a Table attribute; declare the " + "datatype as Table to use it" + ) + + if element_type is not None: + # ``Array1D[np.int32]`` says the element type; an explicit array_dtype + # would be a second, possibly contradictory, source for it. + if "array_dtype" in resolved: + raise TypeError( + "The element type is already given by the datatype subscript; " + "drop the array_dtype argument" + ) + resolved["array_dtype"] = element_type + + return cast(Meta, resolved) diff --git a/src/fastcs/attributes/attribute_io.py b/src/fastcs/attributes/attribute_io.py deleted file mode 100644 index bc2749770..000000000 --- a/src/fastcs/attributes/attribute_io.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Any, Generic, cast, get_args - -from fastcs.attributes.attr_r import AttrR -from fastcs.attributes.attr_w import AttrW -from fastcs.attributes.attribute_io_ref import AttributeIORef, AttributeIORefT -from fastcs.datatypes import DType_T -from fastcs.tracer import Tracer - - -class AttributeIO(Generic[DType_T, AttributeIORefT], Tracer): - """Base class for performing IO for an `Attribute` - - This class should be inherited to implement reading and writing values from - ``Attributes`` via some API. For read, ``Attribute``s implement the ``update`` - method and for write, ``Attribute`` implement the ``send`` method. - - Concrete implementations of this class must be parameterised with a specific - ``AttributeIORef`` that defines exactly what part of the API the ``Attribute`` - corresponds to. See the docstring for `AttributeIORef` for more information. - """ - - ref_type = AttributeIORef - - def __init_subclass__(cls) -> None: - # sets ref_type from subclass generic args - # from python 3.12 we can use types.get_original_bases - args = get_args(cast(Any, cls).__orig_bases__[0]) - cls.ref_type = args[1] - - def __init__(self): - super().__init__() - - async def update(self, attr: AttrR[DType_T, AttributeIORefT]) -> None: - """Update `AttrR` value from device - - This method will be called in `AttrR.update` in a background task. - - Exceptions raised by this method will be caught and logged with a full stack - trace. If using targeted try-except blocks to log more specific errors, this - should be done with stack trace and exceptions should be re-raised to be handled - by FastCS. - - """ - raise NotImplementedError() - - async def send(self, attr: AttrW[DType_T, AttributeIORefT], value: DType_T) -> None: - """Send `Attribute` value to device - - This method will be called in `AttrW.put`, generally from a `Transport`. - - Exceptions raised by this method will be caught and logged with a full stack - trace. If using targetted try-except blocks to log more specific errors, this - should be done with stack trace and exceptions should be re-raised to be handled - by FastCS.. - - """ - raise NotImplementedError() - - -AnyAttributeIO = AttributeIO[Any] diff --git a/src/fastcs/attributes/attribute_io_ref.py b/src/fastcs/attributes/attribute_io_ref.py deleted file mode 100644 index 575025822..000000000 --- a/src/fastcs/attributes/attribute_io_ref.py +++ /dev/null @@ -1,26 +0,0 @@ -from dataclasses import KW_ONLY, dataclass - -from typing_extensions import TypeVar - - -@dataclass -class AttributeIORef: - """Base for references to define IO for an ``Attribute`` over an API. - - This object acts as a specification of the API that its corresponding - ``AttributeIO`` should access for a given ``Attribute``. The fields necessary to - distinguish between different ``Attributes`` is an implementation detail of the IO, - but some examples are a string to send over a TCP port, or URI within an HTTP - server. - """ - - # Make fields keyword-only so that child classes can have fields without defaults - _: KW_ONLY - update_period: float | None = None - """Period in seconds between attribute updates, or `ONCE`""" - - -AttributeIORefT = TypeVar( - "AttributeIORefT", bound=AttributeIORef, default=AttributeIORef, covariant=True -) -"""An `AttributeIORef` for an `Attribute`""" diff --git a/src/fastcs/attributes/severity.py b/src/fastcs/attributes/severity.py new file mode 100644 index 000000000..cbead56b7 --- /dev/null +++ b/src/fastcs/attributes/severity.py @@ -0,0 +1,20 @@ +from enum import Enum + + +class Severity(Enum): + """How wrong a value is, if at all. + + A FastCS-native enum that happens to use the same strings as EPICS alarm + severities, so a driver or transport speaking EPICS does not have to + translate. It is not EPICS-specific: a Tango event push or any other IO can + report severity the same way, through `Update`. + """ + + NO_ALARM = "NO_ALARM" + """The value is good""" + MINOR = "MINOR" + """The value is outside its warning range, or the device reports a minor fault""" + MAJOR = "MAJOR" + """The value is outside its alarm range, or the device reports a major fault""" + INVALID = "INVALID" + """The value could not be read, or cannot be trusted""" diff --git a/src/fastcs/attributes/update.py b/src/fastcs/attributes/update.py new file mode 100644 index 000000000..dde4addb4 --- /dev/null +++ b/src/fastcs/attributes/update.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic + +from fastcs.attributes.severity import Severity +from fastcs.datatypes import DType_T + + +@dataclass +class Update(Generic[DType_T]): + """A value returned from a getter or setter, with optional metadata. + + A getter or setter may return a bare value, or wrap it in ``Update`` to say + more about it: + + - ``timestamp`` - when the value was obtained. ``None`` means the framework + should stamp it with the time the update was received. + - ``severity`` - how wrong the value is, if the source says. ``None`` means + the source did not report one, which is read as ``Severity.NO_ALARM``. + - ``setpoint`` - a setpoint to publish alongside the readback. ``None`` leaves + the cached setpoint untouched. + + A bare value returned from a setter is equivalent to + ``Update(readback=value, setpoint=value)`` - the device's accepted or clamped + value, which is both what it will report and what was asked of it. + + The value/timestamp/severity trio follows the shape of bluesky's ``Reading`` + so the two read the same way, but shares no code with it. + """ + + readback: DType_T + timestamp: float | None = None + setpoint: DType_T | None = None + severity: Severity | None = None diff --git a/src/fastcs/control_system.py b/src/fastcs/control_system.py index e8604d703..44467fe63 100644 --- a/src/fastcs/control_system.py +++ b/src/fastcs/control_system.py @@ -7,9 +7,8 @@ from IPython.terminal.embed import InteractiveShellEmbed -from fastcs.controllers import Controller, ControllerAPI +from fastcs.controllers import Controller, ControllerAPI, ControllerRunner from fastcs.logging import logger -from fastcs.methods import ScanCallback from fastcs.tracer import Tracer from fastcs.transports import Transport @@ -62,10 +61,7 @@ def __init__( self._transports = transports self._loop = loop or asyncio.get_event_loop() - self._scan_coros: list[ScanCallback] = [] - self._initial_coros: list[ScanCallback] = [] - - self._scan_tasks: set[asyncio.Task] = set() + self._runner = ControllerRunner(self._controllers, self._loop) self.controller_apis: list[ControllerAPI] = [] def run(self, interactive: bool = True): @@ -84,25 +80,6 @@ def run(self, interactive: bool = True): self._loop.add_signal_handler(signal.SIGTERM, serve.cancel) self._loop.run_until_complete(serve) - async def _run_initial_coros(self): - for coro in self._initial_coros: - await coro() - - async def _start_scan_tasks(self): - self._scan_tasks = {self._loop.create_task(coro()) for coro in self._scan_coros} - - def _stop_scan_tasks(self): - for task in self._scan_tasks: - if not task.done(): - try: - task.cancel() - except (asyncio.CancelledError, RuntimeError): - pass - except Exception as e: - raise RuntimeError("Unhandled exception in stop scan tasks") from e - - self._scan_tasks.clear() - async def serve(self, interactive: bool = True) -> None: """Serve the control system over the given transports on the current event loop @@ -118,18 +95,10 @@ async def serve(self, interactive: bool = True) -> None: interactive: Whether to create an interactive IPython shell """ - for controller in self._controllers: - await controller.initialise() - controller.post_initialise() - - self.controller_apis = [] - self._scan_coros = [] - self._initial_coros = [] - for controller in self._controllers: - api, scan_coros, initial_coros = controller.create_api_and_tasks() - self.controller_apis.append(api) - self._scan_coros.extend(scan_coros) - self._initial_coros.extend(initial_coros) + # Build the APIs before wiring transports to them: a transport + # registers its callbacks when it connects, and would miss the first + # readback if the controllers had already started. + self.controller_apis = await self._runner.setup() context = { "controllers": {_context_key(c): c for c in self._controllers}, @@ -172,10 +141,7 @@ async def block_forever(): transports=f"[{', '.join(str(t) for t in self._transports)}]", ) - for controller in self._controllers: - await controller.connect() - await self._run_initial_coros() - await self._start_scan_tasks() + await self._runner.start() try: await asyncio.gather(*coros) @@ -185,15 +151,7 @@ async def block_forever(): logger.exception("Unhandled exception in serve") finally: logger.info("Shutting down FastCS") - self._stop_scan_tasks() - for controller in self._controllers: - try: - await controller.disconnect() - except Exception: - logger.exception( - "Exception during disconnect", - controller=_context_key(controller), - ) + await self._runner.stop() async def _interactive_shell(self, context: dict[str, Any]): """Spawn interactive shell in another thread and wait for it to complete.""" @@ -222,4 +180,4 @@ async def interactive_shell( await stop_event.wait() def __del__(self): - self._stop_scan_tasks() + self._runner._cancel_tasks() # noqa: SLF001 diff --git a/src/fastcs/controllers/__init__.py b/src/fastcs/controllers/__init__.py index b982292de..e3fe4106e 100644 --- a/src/fastcs/controllers/__init__.py +++ b/src/fastcs/controllers/__init__.py @@ -2,3 +2,4 @@ from .controller import Controller as Controller from .controller_api import ControllerAPI as ControllerAPI from .controller_vector import ControllerVector as ControllerVector +from .runner import ControllerRunner as ControllerRunner diff --git a/src/fastcs/controllers/base_controller.py b/src/fastcs/controllers/base_controller.py index 8b29ed6d2..ef3b76252 100755 --- a/src/fastcs/controllers/base_controller.py +++ b/src/fastcs/controllers/base_controller.py @@ -1,8 +1,7 @@ from __future__ import annotations -from collections import Counter -from collections.abc import Sequence from copy import deepcopy +from inspect import getattr_static from typing import ( TypeVar, _GenericAlias, # type: ignore @@ -11,7 +10,7 @@ get_type_hints, ) -from fastcs.attributes import AnyAttributeIO, Attribute, AttrR, AttrW, HintedAttribute +from fastcs.attributes import Attribute, HintedAttribute, UnboundAttr from fastcs.controllers.controller_api import ControllerAPI from fastcs.logging import logger from fastcs.methods import Command, Method, Scan, UnboundCommand, UnboundScan @@ -41,7 +40,6 @@ def __init__( self, path: list[str] | None = None, description: str | None = None, - ios: Sequence[AnyAttributeIO] | None = None, ) -> None: super().__init__() @@ -64,10 +62,6 @@ def __init__( self._bind_attrs() - ios = ios or [] - self._attribute_ref_io_map = {io.ref_type: io for io in ios} - self._validate_io(ios) - def _find_type_hints(self): """Find `Attribute` and `Controller` type hints for introspection validation""" for name, hint in get_type_hints(type(self)).items(): @@ -81,7 +75,7 @@ def _find_type_hints(self): if args is None: dtype = None else: - if len(args) == 2: + if len(args) == 1: dtype = args[0] else: raise TypeError( @@ -124,6 +118,9 @@ def _bind_attrs(self) -> None: controller class. For Methods, this requires creating a bound method from a class method and a controller instance, so that it can be called from any context with the controller instance passed as the ``self`` argument. + An ``@attr``-decorated getter is an `UnboundAttr` declaration rather than + an Attribute, and is bound the same way the Methods are - into a fresh + Attribute whose getter and setter are methods of this instance. """ class_dir = dict.fromkeys(self._walk_mro()) @@ -137,6 +134,13 @@ class method and a controller instance, so that it can be called from any if attr_name == "root_attribute": continue + # An ``UnboundAttr`` is a descriptor that refuses to be read before + # it is bound, so reach past it to the declaration itself. + declaration = getattr_static(self, attr_name, None) + if isinstance(declaration, UnboundAttr): + self.add_attribute(attr_name, declaration.bind(self)) + continue + attr = getattr(self, attr_name, None) if isinstance(attr, Attribute): setattr(self, attr_name, deepcopy(attr)) @@ -156,16 +160,6 @@ class method and a controller instance, so that it can be called from any ): self.add_scan(attr_name, unbound_scan.bind(self)) - def _validate_io(self, ios: Sequence[AnyAttributeIO]): - """Validate that there is exactly one AttributeIO class registered to the - controller for each type of AttributeIORef belonging to the attributes of the - controller""" - for ref_type, count in Counter([io.ref_type for io in ios]).items(): - if count > 1: - raise RuntimeError( - f"More than one AttributeIO class handles {ref_type.__name__}" - ) - def __repr__(self): name = self.__class__.__name__ path = ".".join(self.path) or None @@ -192,7 +186,6 @@ async def initialise(self): def post_initialise(self): """Hook to call after all attributes added, before serving the application""" self._validate_type_hints() - self._connect_attribute_ios() def _validate_type_hints(self): """Validate all type-hints were introspected""" @@ -260,28 +253,6 @@ def _validate_hinted_controller(self, name: str): sub_controller=controller, ) - def _connect_attribute_ios(self) -> None: - """Connect ``Attribute`` callbacks to ``AttributeIO``s""" - for attr in self.__attributes.values(): - ref = attr.io_ref if attr.has_io_ref() else None - if ref is None: - continue - - io = self._attribute_ref_io_map.get(type(ref)) - if io is None: - raise ValueError( - f"{self.__class__.__name__} does not have an AttributeIO " - f"to handle {attr.io_ref.__class__.__name__}" - ) - - if isinstance(attr, AttrW): - attr.set_on_put_callback(io.send) - if isinstance(attr, AttrR): - attr.set_update_callback(io.update) - - for controller in self.sub_controllers.values(): - controller._connect_attribute_ios() # noqa: SLF001 - @property def path(self) -> list[str]: """Path prefix of attributes, recursively including parent Controllers.""" @@ -323,12 +294,12 @@ def add_attribute(self, name, attr: Attribute): f"hinted attribute '{name}' does not match defined access mode. " f"Expected '{hint.attr_type.__name__}' got '{type(attr).__name__}'." ) - if hint.dtype is not None and hint.dtype != attr.datatype.dtype: + if hint.dtype is not None and hint.dtype != attr.dtype: raise RuntimeError( f"Controller '{self.__class__.__name__}' introspection of " f"hinted attribute '{name}' does not match defined datatype. " f"Expected '{hint.dtype.__name__}', " - f"got '{attr.datatype.dtype.__name__}'." + f"got '{attr.dtype.__name__}'." ) attr.set_name(name) diff --git a/src/fastcs/controllers/controller.py b/src/fastcs/controllers/controller.py index a6c726027..0bee8d7d8 100755 --- a/src/fastcs/controllers/controller.py +++ b/src/fastcs/controllers/controller.py @@ -2,9 +2,7 @@ from collections import defaultdict from collections.abc import Sequence -from fastcs.attributes import AnyAttributeIO from fastcs.attributes.attr_r import AttrR -from fastcs.attributes.attribute_io_ref import AttributeIORef from fastcs.controllers.base_controller import BaseController from fastcs.controllers.controller_api import ControllerAPI from fastcs.logging import logger @@ -18,9 +16,8 @@ class Controller(BaseController): def __init__( self, description: str | None = None, - ios: Sequence[AnyAttributeIO] | None = None, ) -> None: - super().__init__(description=description, ios=ios) + super().__init__(description=description) self._connected = False def add_sub_controller(self, name: str, sub_controller: BaseController): @@ -31,6 +28,15 @@ def add_sub_controller(self, name: str, sub_controller: BaseController): ) return super().add_sub_controller(name, sub_controller) + @property + def connected(self) -> bool: + """Whether the controller believes it can talk to its device. + + Set by `connect`/`reconnect`, and cleared when a scan task raises. The + `ControllerRunner` reads it to decide when to reconnect. + """ + return self._connected + async def connect(self) -> None: """Hook to perform initial connection to device @@ -83,14 +89,18 @@ def create_api_and_tasks( scan_dict[method.period].append(method.fn) for attribute in api.attributes.values(): - match attribute: - case AttrR(_io_ref=AttributeIORef(update_period=update_period)): - if update_period is ONCE: - initial_coros.append(attribute.bind_update_callback()) - elif update_period is not None: - scan_dict[update_period].append( - attribute.bind_update_callback() - ) + if not (isinstance(attribute, AttrR) and attribute.has_getter()): + continue + + poll_period = attribute.poll_period + + async def poll_attribute(attribute: AttrR = attribute) -> None: + await attribute.poll() + + if poll_period is ONCE: + initial_coros.append(poll_attribute) + elif poll_period is not None: + scan_dict[poll_period].append(poll_attribute) periodic_scan_coros: list[ScanCallback] = [] for period, methods in scan_dict.items(): diff --git a/src/fastcs/controllers/controller_vector.py b/src/fastcs/controllers/controller_vector.py index 119258272..739952fc2 100755 --- a/src/fastcs/controllers/controller_vector.py +++ b/src/fastcs/controllers/controller_vector.py @@ -1,6 +1,5 @@ -from collections.abc import Iterator, Mapping, MutableMapping, Sequence +from collections.abc import Iterator, Mapping, MutableMapping -from fastcs.attributes import AnyAttributeIO from fastcs.controllers.base_controller import BaseController from fastcs.controllers.controller import Controller from fastcs.util import Controller_T @@ -18,9 +17,8 @@ def __init__( self, children: Mapping[int, Controller_T], description: str | None = None, - ios: Sequence[AnyAttributeIO] | None = None, ) -> None: - super().__init__(description=description, ios=ios) + super().__init__(description=description) self._children: dict[int, Controller_T] = {} for index, child in children.items(): self[index] = child diff --git a/src/fastcs/controllers/runner.py b/src/fastcs/controllers/runner.py new file mode 100644 index 000000000..ead653e9e --- /dev/null +++ b/src/fastcs/controllers/runner.py @@ -0,0 +1,151 @@ +import asyncio +from collections.abc import Sequence + +from fastcs.controllers.controller import Controller +from fastcs.controllers.controller_api import ControllerAPI +from fastcs.logging import logger +from fastcs.methods import ScanCallback + +RECONNECT_PERIOD = 1.0 +"""Seconds between checks for a controller that has dropped its connection""" + + +class ControllerRunner: + """Runs one or more `Controller` s, without serving them anywhere. + + This owns the whole controller lifecycle - initialising, connecting, + running the initial and periodic tasks, reconnecting after a failure, and + tidying up - and nothing about how the controllers are presented. `FastCS` + uses it and adds transports on top; an embedded caller that only wants the + controllers running can use it on its own:: + + runner = ControllerRunner(controller) + await runner.start() + ... + await runner.stop() + + Starting has two halves, because anything serving the controllers needs + their `ControllerAPI` before the first values are read: ``setup`` initialises + them and builds the APIs, and ``start`` connects and starts the tasks. + Calling ``start`` on its own does both. + + **Idempotency is the caller's responsibility.** Starting a running runner, + or stopping a stopped one, is not defined. + + Args: + controllers: The controller(s) to run. Accepts either a single + ``Controller`` or a sequence of them. + loop: Optional event loop to create the tasks in + + """ + + def __init__( + self, + controllers: Controller | Sequence[Controller], + loop: asyncio.AbstractEventLoop | None = None, + ) -> None: + if isinstance(controllers, Controller): + controllers = [controllers] + self._controllers: list[Controller] = list(controllers) + self._loop = loop + + self._controller_apis: list[ControllerAPI] = [] + self._scan_coros: list[ScanCallback] = [] + self._initial_coros: list[ScanCallback] = [] + self._tasks: set[asyncio.Task] = set() + + @property + def controller_apis(self) -> list[ControllerAPI]: + """The API of each controller. Empty until ``setup`` has run.""" + return self._controller_apis + + async def setup(self) -> list[ControllerAPI]: + """Initialise the controllers and build their APIs. + + Runs before anything connects, so that a transport can be wired to the + APIs and catch the first readback. + + Returns: + The API of each controller, in the order they were given + + """ + for controller in self._controllers: + await controller.initialise() + controller.post_initialise() + + self._controller_apis = [] + self._scan_coros = [] + self._initial_coros = [] + for controller in self._controllers: + api, scan_coros, initial_coros = controller.create_api_and_tasks() + self._controller_apis.append(api) + self._scan_coros.extend(scan_coros) + self._initial_coros.extend(initial_coros) + + return self._controller_apis + + async def start(self) -> None: + """Connect the controllers and start their tasks. + + Runs ``setup`` first if it has not already run. + """ + if not self._controller_apis: + await self.setup() + + for controller in self._controllers: + await controller.connect() + + for coro in self._initial_coros: + await coro() + + loop = self._loop or asyncio.get_event_loop() + self._tasks = {loop.create_task(coro()) for coro in self._scan_coros} + self._tasks |= { + loop.create_task(self._reconnect_loop(controller)) + for controller in self._controllers + } + + async def stop(self) -> None: + """Stop the tasks and disconnect the controllers.""" + self._cancel_tasks() + + for controller in self._controllers: + try: + await controller.disconnect() + except Exception: + logger.exception( + "Exception during disconnect", controller=controller.path + ) + + async def _reconnect_loop(self, controller: Controller) -> None: + """Bring a controller back after its scan tasks hit an error. + + A scan task that raises marks its controller disconnected and pauses + rather than dying, so something has to try to bring it back. That is the + runner's job rather than the controller's, so that every controller + reconnects the same way whether or not its author thought about it. + """ + while True: + await asyncio.sleep(RECONNECT_PERIOD) + + if controller.connected: + continue + + logger.info("Attempting to reconnect", controller=controller.path) + try: + await controller.reconnect() + except Exception: + logger.exception("Reconnect failed", controller=controller.path) + + def _cancel_tasks(self) -> None: + # ``Task.cancel`` does not raise - it returns whether the task was + # cancellable - so the guards the old FastCS._stop_scan_tasks wrapped + # this in never fired. + for task in self._tasks: + if not task.done(): + task.cancel() + + self._tasks.clear() + + def __del__(self): + self._cancel_tasks() diff --git a/src/fastcs/datatypes/__init__.py b/src/fastcs/datatypes/__init__.py index fc108c9d5..8cbe51bde 100644 --- a/src/fastcs/datatypes/__init__.py +++ b/src/fastcs/datatypes/__init__.py @@ -1,11 +1,28 @@ -from ._util import numpy_to_fastcs_datatype as numpy_to_fastcs_datatype -from .bool import Bool as Bool -from .datatype import DataType as DataType -from .datatype import DType as DType -from .datatype import DType_T as DType_T -from .enum import Enum as Enum -from .float import Float as Float -from .int import Int as Int -from .string import String as String -from .table import Table as Table -from .waveform import Waveform as Waveform +from ._util import numpy_to_python_type as numpy_to_python_type +from .limits import Limits as Limits +from .limits import NumericLimits as NumericLimits +from .meta import DEFAULT_ARRAY_SHAPE as DEFAULT_ARRAY_SHAPE +from .meta import DEFAULT_PRECISION as DEFAULT_PRECISION +from .meta import Array1DMeta as Array1DMeta +from .meta import BoolMeta as BoolMeta +from .meta import CommonMeta as CommonMeta +from .meta import EnumMeta as EnumMeta +from .meta import FloatMeta as FloatMeta +from .meta import IntMeta as IntMeta +from .meta import Meta as Meta +from .meta import StrMeta as StrMeta +from .meta import TableMeta as TableMeta +from .types import Array1D as Array1D +from .types import Array_T as Array_T +from .types import DType as DType +from .types import DType_T as DType_T +from .types import Enum_T as Enum_T +from .types import Inferred_T as Inferred_T +from .types import Table as Table +from .types import is_array_datatype as is_array_datatype +from .types import resolve_datatype as resolve_datatype +from .validation import array_dtype_of as array_dtype_of +from .validation import default_value as default_value +from .validation import validate_meta as validate_meta +from .validation import validate_value as validate_value +from .validation import values_equal as values_equal diff --git a/src/fastcs/datatypes/_numeric.py b/src/fastcs/datatypes/_numeric.py deleted file mode 100644 index 8b8f44da8..000000000 --- a/src/fastcs/datatypes/_numeric.py +++ /dev/null @@ -1,38 +0,0 @@ -from dataclasses import dataclass -from typing import Any, TypeVar - -from fastcs.datatypes.datatype import DataType - -Numeric_T = TypeVar("Numeric_T", int, float) -"""A numeric type supported by a corresponding FastCS Attribute DataType""" - - -@dataclass(frozen=True) -class _Numeric(DataType[Numeric_T]): - """Base class for numeric FastCS DataType classes""" - - units: str | None = None - """The units of the numeric value""" - min: Numeric_T | None = None - """The minimum allowed value - values below this will raise an exception""" - max: Numeric_T | None = None - """The maximum allowed value - values above this will raise an exception""" - min_alarm: Numeric_T | None = None - """The minimum alarm limit - values below this will be set with an alarm state""" - max_alarm: Numeric_T | None = None - """The maximum alarm limit - values above this will be set with an alarm state""" - - def validate(self, value: Any) -> Numeric_T: - _value = super().validate(value) - - if self.min is not None and _value < self.min: - raise ValueError(f"Value {_value} is less than minimum {self.min}") - - if self.max is not None and _value > self.max: - raise ValueError(f"Value {_value} is greater than maximum {self.max}") - - return _value - - @property - def initial_value(self) -> Numeric_T: - return self.dtype(0) diff --git a/src/fastcs/datatypes/_util.py b/src/fastcs/datatypes/_util.py index b590f7ff8..bd55ac958 100644 --- a/src/fastcs/datatypes/_util.py +++ b/src/fastcs/datatypes/_util.py @@ -1,21 +1,18 @@ import numpy as np -from fastcs.datatypes.bool import Bool -from fastcs.datatypes.datatype import DataType -from fastcs.datatypes.float import Float -from fastcs.datatypes.int import Int -from fastcs.datatypes.string import String +from fastcs.datatypes.types import DType -def numpy_to_fastcs_datatype(np_type) -> DataType: - """Converts numpy types to fastcs types for widget creation. - Only types important for widget creation are explicitly converted +def numpy_to_python_type(np_type) -> type[DType]: + """Converts numpy types to python types for widget creation. + + Only types important for widget creation are explicitly converted. """ if np.issubdtype(np_type, np.integer): - return Int() + return int elif np.issubdtype(np_type, np.floating): - return Float() + return float elif np.issubdtype(np_type, np.bool_): - return Bool() + return bool else: - return String() + return str diff --git a/src/fastcs/datatypes/bool.py b/src/fastcs/datatypes/bool.py deleted file mode 100644 index 7b99ae2a9..000000000 --- a/src/fastcs/datatypes/bool.py +++ /dev/null @@ -1,16 +0,0 @@ -from dataclasses import dataclass - -from fastcs.datatypes.datatype import DataType - - -@dataclass(frozen=True) -class Bool(DataType[bool]): - """`DataType` mapping to builtin ``bool``.""" - - @property - def dtype(self) -> type[bool]: - return bool - - @property - def initial_value(self) -> bool: - return False diff --git a/src/fastcs/datatypes/datatype.py b/src/fastcs/datatypes/datatype.py deleted file mode 100644 index 09953bd7d..000000000 --- a/src/fastcs/datatypes/datatype.py +++ /dev/null @@ -1,94 +0,0 @@ -import enum -from abc import abstractmethod -from collections.abc import Sequence -from dataclasses import dataclass -from typing import Any, Generic, TypeVar - -import numpy as np - -DType = ( - int # Int - | float # Float - | bool # Bool - | str # String - | enum.Enum # Enum - | np.ndarray # Waveform / Table -) -"""A builtin (or numpy) type supported by a corresponding FastCS Attribute DataType""" - -DType_T = TypeVar("DType_T", bound=DType) -"""A TypeVar of `DType` for use in generic classes and functions""" - - -@dataclass(frozen=True) -class DataType(Generic[DType_T]): - """Generic datatype mapping to a python type, with additional metadata.""" - - @property - @abstractmethod - def dtype(self) -> type[DType_T]: # Using property due to lack of Generic ClassVars - """Underlying python type""" - raise NotImplementedError() - - @property - @abstractmethod - def initial_value(self) -> DType_T: - """Fallback initial value if not specified in `Attribute`""" - raise NotImplementedError() - - def validate(self, value: Any) -> DType_T: - """Validate a value against the datatype. - - The base implementation is to try the cast and raise a useful error if it fails. - - Child classes can implement logic before calling ``super.validate(value)`` to - modify the value passed in and help the cast succeed or after to perform further - validation of the coerced type. - - Args: - value: The value to validate - - Returns: - The validated value - - Raises: - ValueError: If the value cannot be coerced - - """ - if isinstance(value, self.dtype): - return value - - try: - return self.dtype(value) - except (ValueError, TypeError) as e: - raise ValueError(f"Failed to cast {value} to type {self.dtype}") from e - - @staticmethod - def equal(value1: DType_T, value2: DType_T) -> bool: - """Compare two values for equality - - Child classes can override this if the underlying type does not implement - ``__eq__`` or to define custom logic. - - Args: - value1: The first value to compare - value2: The second value to compare - - Returns: - `True` if the values are equal - - """ - return value1 == value2 - - @classmethod - def all_equal(cls, values: Sequence[DType_T]) -> bool: - """Compare a sequence of values for equality - - Args: - values: Values to compare - - Returns: - `True` if all values are equal, else `False` - - """ - return all(cls.equal(values[0], value) for value in values[1:]) diff --git a/src/fastcs/datatypes/enum.py b/src/fastcs/datatypes/enum.py deleted file mode 100644 index e490f5c76..000000000 --- a/src/fastcs/datatypes/enum.py +++ /dev/null @@ -1,37 +0,0 @@ -import enum -from dataclasses import dataclass -from functools import cached_property -from typing import Generic, TypeVar - -from fastcs.datatypes.datatype import DataType - -Enum_T = TypeVar("Enum_T", bound=enum.Enum) -"""A builtin Enum type""" - - -@dataclass(frozen=True) -class Enum(Generic[Enum_T], DataType[Enum_T]): - enum_cls: type[Enum_T] - - def __post_init__(self): - if not issubclass(self.enum_cls, enum.Enum): - raise ValueError("Enum class has to take an Enum.") - - def index_of(self, value: Enum_T) -> int: - return self.members.index(value) - - @cached_property - def members(self) -> list[Enum_T]: - return list(self.enum_cls) - - @cached_property - def names(self) -> list[str]: - return [member.name for member in self.members] - - @property - def dtype(self) -> type[Enum_T]: - return self.enum_cls - - @property - def initial_value(self) -> Enum_T: - return self.members[0] diff --git a/src/fastcs/datatypes/float.py b/src/fastcs/datatypes/float.py deleted file mode 100644 index 6e24f384f..000000000 --- a/src/fastcs/datatypes/float.py +++ /dev/null @@ -1,24 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from fastcs.datatypes._numeric import _Numeric - - -@dataclass(frozen=True) -class Float(_Numeric[float]): - """`DataType` mapping to builtin ``float``.""" - - prec: int = 2 - """Number of decimal places to represent value""" - - @property - def dtype(self) -> type[float]: - return float - - def validate(self, value: Any) -> float: - _value = super().validate(value) - - if self.prec is not None: - _value = round(_value, self.prec) - - return _value diff --git a/src/fastcs/datatypes/int.py b/src/fastcs/datatypes/int.py deleted file mode 100644 index 31858e97e..000000000 --- a/src/fastcs/datatypes/int.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass - -from fastcs.datatypes._numeric import _Numeric - - -@dataclass(frozen=True) -class Int(_Numeric[int]): - """`DataType` mapping to builtin ``int``.""" - - @property - def dtype(self) -> type[int]: - return int diff --git a/src/fastcs/datatypes/limits.py b/src/fastcs/datatypes/limits.py new file mode 100644 index 000000000..635384ad5 --- /dev/null +++ b/src/fastcs/datatypes/limits.py @@ -0,0 +1,83 @@ +"""Numeric limits, aligned with the bluesky event-model (ADR 0017).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic, TypeVar + +Numeric_T = TypeVar("Numeric_T", int, float) +"""A numeric type that can carry limits""" + + +@dataclass(frozen=True) +class Limits(Generic[Numeric_T]): + """A pair of bounds on a numeric value. + + Either end may be ``None``, meaning unbounded in that direction. + """ + + low: Numeric_T | None = None + """The lower bound, or ``None`` for unbounded""" + high: Numeric_T | None = None + """The upper bound, or ``None`` for unbounded""" + + def contains(self, other: Limits[Numeric_T]) -> bool: + """Whether ``other`` lies within this range. + + An unbounded end of ``self`` contains any value; an unbounded end of + ``other`` is only contained by an unbounded end of ``self``. + """ + if self.low is not None and (other.low is None or other.low < self.low): + return False + if self.high is not None and (other.high is None or other.high > self.high): + return False + + return True + + +UNBOUNDED: Limits = Limits() +"""Limits with neither end set""" + + +@dataclass(frozen=True) +class NumericLimits(Generic[Numeric_T]): + """The four categories of limit on a numeric attribute. + + All four are optional and are resolved on construction, so that after + construction every category holds a `Limits` - unbounded if nothing + determined it. The rules (ADR 0017) are: + + - supply none and all four are unbounded; + - a ``display`` range with no ``control`` range gives ``control`` the + display range - what a device may be driven to defaults to what it is + shown as spanning; + - an ``alarm`` range with no ``warning`` range gives ``warning`` the alarm + range; + - supplying both asserts that ``warning`` lies within ``alarm``, since a + warning outside the alarm range could never be the milder condition. + + >>> limits = NumericLimits(display=Limits(0.0, 10.0)) + >>> limits.control + Limits(low=0.0, high=10.0) + """ + + control: Limits[Numeric_T] = UNBOUNDED + """The range the attribute may be driven to""" + display: Limits[Numeric_T] = UNBOUNDED + """The range the attribute is displayed over""" + alarm: Limits[Numeric_T] = UNBOUNDED + """The range outside which the attribute is in alarm""" + warning: Limits[Numeric_T] = UNBOUNDED + """The range outside which the attribute is in a warning state""" + + def __post_init__(self) -> None: + if self.control == UNBOUNDED and self.display != UNBOUNDED: + object.__setattr__(self, "control", self.display) + + if self.warning == UNBOUNDED and self.alarm != UNBOUNDED: + object.__setattr__(self, "warning", self.alarm) + elif self.alarm != UNBOUNDED and not self.alarm.contains(self.warning): + raise ValueError( + f"Warning limits {self.warning} are not within alarm limits " + f"{self.alarm}" + ) diff --git a/src/fastcs/datatypes/meta.py b/src/fastcs/datatypes/meta.py new file mode 100644 index 000000000..4dd8a2f7a --- /dev/null +++ b/src/fastcs/datatypes/meta.py @@ -0,0 +1,115 @@ +"""Per-datatype metadata, as typed dicts (ADR 0014). + +Each python type an `Attribute` can hold has a ``*Meta`` typed dict saying what +metadata is meaningful for it - ``precision`` for a ``float``, ``length`` for a +``str``, ``shape`` for an array. The ``Attr*`` constructors unpack the right one +for the datatype they were given, so ``AttrRW(str, precision=3)`` is a static +type error rather than a field silently ignored at runtime. + +`Meta` is the superset of every field, all optional. It is what a declarative +extras object (such as the demo's ``SCPIParam``) takes, since an +``Annotated[...]`` extra cannot tie its metadata to the attribute's datatype +statically - the ``ControllerFiller`` validates those at fill time instead. +""" + +from __future__ import annotations + +from typing import Any, TypedDict + +from numpy.typing import DTypeLike + +from fastcs.datatypes.limits import NumericLimits + +DEFAULT_PRECISION = 2 +"""Decimal places a ``float`` attribute is rounded to when unspecified""" + +DEFAULT_ARRAY_SHAPE: tuple[int, ...] = (2000,) +"""Maximum shape of an array attribute when unspecified""" + + +class CommonMeta(TypedDict, total=False): + """Metadata meaningful for an attribute of any datatype.""" + + description: str + """Human readable description of what the attribute is""" + group: str + """Name of the group to display the attribute under""" + + +class BoolMeta(CommonMeta, total=False): + """Metadata for a ``bool`` attribute.""" + + +class IntMeta(CommonMeta, total=False): + """Metadata for an ``int`` attribute.""" + + units: str + """The units of the value""" + limits: NumericLimits[int] + """The control, display, alarm and warning ranges of the value""" + + +class FloatMeta(CommonMeta, total=False): + """Metadata for a ``float`` attribute.""" + + units: str + """The units of the value""" + limits: NumericLimits[float] + """The control, display, alarm and warning ranges of the value""" + precision: int + """Number of decimal places to round to and display""" + + +class StrMeta(CommonMeta, total=False): + """Metadata for a ``str`` attribute.""" + + length: int + """Maximum length of the string. Must be >= 1""" + + +class EnumMeta(CommonMeta, total=False): + """Metadata for an `enum.Enum` attribute. + + Display only - the choices come from the enum class itself. + """ + + +class Array1DMeta(CommonMeta, total=False): + """Metadata for an `Array1D` attribute.""" + + array_dtype: DTypeLike + """Numpy element type, if not given by the datatype subscript""" + shape: tuple[int, ...] + """Maximum shape of the array""" + + +class TableMeta(CommonMeta, total=False): + """Metadata for a `Table` attribute.""" + + structured_dtype: list[tuple[str, DTypeLike]] + """The columns of the table, as a numpy structured dtype""" + + +class Meta(CommonMeta, total=False): + """Every metadata field, all optional. + + The spelling for metadata that cannot be tied to a datatype statically - + a declarative extras object collecting whatever the protocol layer was + told, validated against the datatype when the attribute is built. + + Spelled out rather than inheriting every ``*Meta``, because ``IntMeta`` and + ``FloatMeta`` disagree on the type of ``limits``. + """ + + units: str + limits: NumericLimits[int] | NumericLimits[float] + precision: int + length: int + array_dtype: DTypeLike + shape: tuple[int, ...] + structured_dtype: list[tuple[str, DTypeLike]] + + +def meta_fields(meta_cls: Any) -> frozenset[str]: + """The field names of a ``*Meta`` typed dict, including inherited ones.""" + return frozenset(meta_cls.__optional_keys__) | frozenset(meta_cls.__required_keys__) diff --git a/src/fastcs/datatypes/string.py b/src/fastcs/datatypes/string.py deleted file mode 100644 index e4deb15bb..000000000 --- a/src/fastcs/datatypes/string.py +++ /dev/null @@ -1,33 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from fastcs.datatypes.datatype import DataType - - -@dataclass(frozen=True) -class String(DataType[str]): - """`DataType` mapping to builtin ``str``.""" - - length: int | None = None - """Maximum length of string to display in transports. Must be >=1 or None.""" - - def __post_init__(self): - if self.length is not None and self.length < 1: - raise ValueError("String length must be >= 1") - - @property - def dtype(self) -> type[str]: - return str - - @property - def initial_value(self) -> str: - return "" - - def validate(self, value: Any) -> str: - """Truncate string to maximum length - - Returns: - The string, truncated to the maximum length if set - - """ - return super().validate(value)[: self.length] diff --git a/src/fastcs/datatypes/table.py b/src/fastcs/datatypes/table.py deleted file mode 100644 index f8f3a6d60..000000000 --- a/src/fastcs/datatypes/table.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -import numpy as np -from numpy.typing import DTypeLike - -from fastcs.datatypes.datatype import DataType - - -@dataclass(frozen=True) -class Table(DataType[np.ndarray]): - structured_dtype: list[tuple[str, DTypeLike]] - """The structured dtype for numpy array - - See docs for more information: - https://numpy.org/devdocs/user/basics.rec.html#structured-datatype-creation - """ - - @property - def dtype(self) -> type[np.ndarray]: - return np.ndarray - - @property - def initial_value(self) -> np.ndarray: - return np.array([], dtype=self.structured_dtype) - - def validate(self, value: Any) -> np.ndarray: - _value = super().validate(value) - - if self.structured_dtype != _value.dtype: - raise ValueError( - f"Value dtype {_value.dtype.descr} is not the same as the structured " - f"dtype {self.structured_dtype}" - ) - - return _value - - @staticmethod - def equal(value1: np.ndarray, value2: np.ndarray) -> bool: - return np.array_equal(value1, value2) diff --git a/src/fastcs/datatypes/types.py b/src/fastcs/datatypes/types.py new file mode 100644 index 000000000..bea3b7bb7 --- /dev/null +++ b/src/fastcs/datatypes/types.py @@ -0,0 +1,144 @@ +"""The python types a FastCS `Attribute` can hold, and how they are spelled. + +There is no ``DataType`` object: an attribute's datatype *is* a python type, +and everything that used to hang off a ``DataType`` instance - precision, +units, limits, array shape - now travels separately as a ``*Meta`` typed dict +(see :py:mod:`fastcs.datatypes.meta`). +""" + +from __future__ import annotations + +import enum +from typing import Any, TypeAlias, TypeVar, get_args, get_origin + +import numpy as np +from numpy.typing import DTypeLike + +DType = ( + int # int + | float # float + | bool # bool + | str # str + | enum.Enum # any Enum subclass + | np.ndarray # Array1D / Table +) +"""A python type that a FastCS `Attribute` can hold""" + +DType_T = TypeVar("DType_T", bound=DType) +"""A TypeVar of `DType` for use in generic classes and functions""" + +NumpyScalar_T = TypeVar("NumpyScalar_T", bound=np.generic, covariant=True) +"""The element type of a numpy array""" + +Enum_T = TypeVar("Enum_T", bound=enum.Enum) +"""A TypeVar of any `enum.Enum` subclass an attribute can hold""" + +Array_T = TypeVar("Array_T", bound=np.ndarray) +"""A TypeVar of any numpy array an attribute can hold""" + +Inferred_T = TypeVar("Inferred_T", bound=DType) +"""A TypeVar of `DType` for the constructor overload that infers the datatype + +Distinct from `DType_T` because the overload binds it from the getter or setter +in the same signature that annotates ``self``, and a class-scoped TypeVar cannot +be used there. +""" + +Array1D: TypeAlias = np.ndarray[tuple[int], np.dtype[NumpyScalar_T]] +"""A one dimensional numpy array, subscripted with its element type. + +``Array1D[np.int32]`` is both the type hint for an array attribute and the +datatype passed to its constructor - the element type is read straight off the +subscript, so it does not have to be repeated in the metadata:: + + AttrR(Array1D[np.int32], shape=(10,)) + +Neither the subscript nor the ``shape`` has to be repeated in the metadata, and +``shape`` may be left off entirely to take the default. numpy's own +``npt.NDArray[np.int32]`` is the same spelling with an unbounded shape and is +accepted wherever `Array1D` is:: + + AttrR(npt.NDArray[np.int32]) + +Arrays of higher rank have no ophyd-async-compatible spelling; write them as +``np.ndarray`` with an explicit ``array_dtype``:: + + AttrR(np.ndarray, array_dtype=np.int32, shape=(10, 10)) +""" + + +class Table(np.ndarray): + """A structured ("record") numpy array, one field per column. + + Both the type hint and the datatype for a table attribute; the columns are + given as the ``structured_dtype`` metadata:: + + AttrR(Table, structured_dtype=[("index", np.int32), ("value", np.float64)]) + + See https://numpy.org/devdocs/user/basics.rec.html for structured dtypes. + """ + + +_BUILTIN_DTYPES: tuple[type, ...] = (bool, int, float, str) +"""The builtin types an attribute may hold, matched exactly rather than by +subclass - ``bool`` is a subclass of ``int``, and the two are not +interchangeable to a transport.""" + + +def is_array_datatype(dtype: type[DType]) -> bool: + """Whether ``dtype`` is held as a numpy array - an `Array1D` or a `Table`.""" + return issubclass(dtype, np.ndarray) + + +def resolve_datatype(datatype: Any) -> tuple[type[DType], DTypeLike | None]: + """Resolve a datatype as written into the python type an attribute holds. + + Args: + datatype: A datatype spelling - a builtin type, an `enum.Enum` + subclass, `Table`, ``np.ndarray``, or a subscripted `Array1D` + + Returns: + The python type, and the numpy element type carried by the spelling if + it had one (``Array1D[np.int32]`` carries ``np.int32``; a bare + ``np.ndarray`` carries nothing and needs an ``array_dtype``) + + Raises: + TypeError: If ``datatype`` is not a supported spelling + + """ + # ``Array1D[np.int32]`` is a subscripted generic alias rather than a class. + if (origin := get_origin(datatype)) is not None: + if not (isinstance(origin, type) and issubclass(origin, np.ndarray)): + raise TypeError(f"Unsupported datatype {datatype!r}") + + return np.ndarray, _element_type_of(datatype) + + if not isinstance(datatype, type): + raise TypeError( + f"Datatype must be a type, got {datatype!r}. Metadata such as " + "precision or units is passed as keyword arguments, not as part " + "of the datatype." + ) + + if datatype in _BUILTIN_DTYPES or issubclass(datatype, enum.Enum): + return datatype, None + + if issubclass(datatype, np.ndarray): + # ``Table`` and ``Array1D`` are both held as plain ``np.ndarray``; what + # separates them is whether the metadata gives a structured dtype. + return np.ndarray, None + + raise TypeError(f"Unsupported datatype {datatype!r}") + + +def _element_type_of(alias: Any) -> DTypeLike | None: + """The numpy element type of a subscripted ``np.ndarray`` alias, if given.""" + args = get_args(alias) + if len(args) != 2: + return None + + # ``np.ndarray[tuple[int], np.dtype[np.int32]]`` - the element type is the + # argument of the inner ``np.dtype``. + dtype_args = get_args(args[1]) + + return dtype_args[0] if dtype_args else None diff --git a/src/fastcs/datatypes/validation.py b/src/fastcs/datatypes/validation.py new file mode 100644 index 000000000..49cad726a --- /dev/null +++ b/src/fastcs/datatypes/validation.py @@ -0,0 +1,220 @@ +"""Validating and comparing attribute values against a datatype and its metadata. + +This is what the ``DataType`` classes used to do in ``validate``/``equal``/ +``initial_value``; with the datatype reduced to a python type, the behaviour +that depended on the metadata is dispatched here instead. +""" + +from __future__ import annotations + +import enum +from typing import Any, cast + +import numpy as np + +from fastcs.datatypes.limits import NumericLimits +from fastcs.datatypes.meta import ( + DEFAULT_ARRAY_SHAPE, + DEFAULT_PRECISION, + Array1DMeta, + BoolMeta, + EnumMeta, + FloatMeta, + IntMeta, + Meta, + StrMeta, + TableMeta, + meta_fields, +) +from fastcs.datatypes.types import DType, DType_T + +_META_FOR_DTYPE: dict[type, Any] = { + bool: BoolMeta, + int: IntMeta, + float: FloatMeta, + str: StrMeta, +} + + +def meta_class_for(dtype: type[DType], meta: Meta) -> Any: + """The ``*Meta`` typed dict that applies to ``dtype``.""" + if dtype in _META_FOR_DTYPE: + return _META_FOR_DTYPE[dtype] + if issubclass(dtype, enum.Enum): + return EnumMeta + if issubclass(dtype, np.ndarray): + return TableMeta if "structured_dtype" in meta else Array1DMeta + + raise TypeError(f"Unsupported datatype {dtype!r}") + + +def validate_meta(dtype: type[DType], meta: Meta, name: str = "attribute") -> None: + """Check that every field of ``meta`` is meaningful for ``dtype``. + + The runtime counterpart of the ``Unpack[*Meta]`` overloads on the + constructors, for metadata that arrived without a static check - from a + ``ControllerFiller`` extras object, say. + + Args: + dtype: The python type the attribute holds + meta: The metadata to check + name: The attribute's name, to name it in the error + + Raises: + TypeError: If a field is not meaningful for the datatype + + """ + allowed = meta_fields(meta_class_for(dtype, meta)) + for field in meta: + if field not in allowed: + raise TypeError( + f"'{field}' is not valid metadata for {dtype.__name__} " + f"{name} - valid fields are {', '.join(sorted(allowed))}" + ) + + length = meta.get("length") + if length is not None and length < 1: + raise ValueError(f"String length must be >= 1, got {length} for {name}") + + +def array_dtype_of(meta: Meta, element_type: Any = None) -> Any: + """The numpy element type of an array attribute. + + Args: + meta: The attribute's metadata + element_type: The element type carried by the datatype spelling, if any + + Returns: + The numpy element type + + Raises: + TypeError: If neither source gives one + + """ + array_dtype = meta.get("array_dtype", element_type) + if array_dtype is None: + raise TypeError( + "An array attribute needs an element type - subscript the datatype " + "as Array1D[np.int32], or pass array_dtype=np.int32" + ) + + return array_dtype + + +def default_value(dtype: type[DType_T], meta: Meta) -> DType_T: + """The value an attribute holds before anything has set one.""" + if dtype is str: + return cast(DType_T, "") + if dtype is bool: + return cast(DType_T, False) + if dtype in (int, float): + return cast(DType_T, dtype(0)) + if issubclass(dtype, enum.Enum): + return cast(DType_T, next(iter(dtype))) + if issubclass(dtype, np.ndarray): + if (structured_dtype := meta.get("structured_dtype")) is not None: + return cast(DType_T, np.array([], dtype=structured_dtype)) + + return cast( + DType_T, + np.zeros( + meta.get("shape", DEFAULT_ARRAY_SHAPE), + dtype=array_dtype_of(meta), + ), + ) + + raise TypeError(f"Unsupported datatype {dtype!r}") + + +def values_equal(dtype: type[DType], value1: Any, value2: Any) -> bool: + """Whether two values of ``dtype`` are equal. + + Numpy arrays need ``array_equal`` rather than ``==``, which is elementwise. + """ + if issubclass(dtype, np.ndarray): + return bool(np.array_equal(value1, value2)) + + return bool(value1 == value2) + + +def validate_value(dtype: type[DType_T], meta: Meta, value: Any) -> DType_T: + """Coerce a value to ``dtype`` and check it against ``meta``. + + Args: + dtype: The python type the attribute holds + meta: The attribute's metadata + value: The value to validate + + Returns: + The validated value + + Raises: + ValueError: If the value cannot be coerced, or breaks the metadata + + """ + if issubclass(dtype, np.ndarray): + return cast(DType_T, _validate_array(meta, value)) + + coerced = _coerce(dtype, value) + + if dtype is float: + precision = meta.get("precision", DEFAULT_PRECISION) + coerced = cast(DType_T, round(cast(float, coerced), precision)) + elif dtype is str: + return cast(DType_T, cast(str, coerced)[: meta.get("length")]) + + if dtype in (int, float): + _check_limits(cast(int | float, coerced), meta.get("limits")) + + return coerced + + +def _coerce(dtype: type[DType_T], value: Any) -> DType_T: + if isinstance(value, dtype): + return value + + try: + return dtype(value) # pyright: ignore[reportCallIssue] + except (ValueError, TypeError) as e: + raise ValueError(f"Failed to cast {value} to type {dtype}") from e + + +def _check_limits(value: int | float, limits: NumericLimits | None) -> None: + if limits is None: + return + + control = limits.control + if control.low is not None and value < control.low: + raise ValueError(f"Value {value} is less than minimum {control.low}") + if control.high is not None and value > control.high: + raise ValueError(f"Value {value} is greater than maximum {control.high}") + + +def _validate_array(meta: Meta, value: Any) -> np.ndarray: + if (structured_dtype := meta.get("structured_dtype")) is not None: + array = np.asarray(value) + if structured_dtype != array.dtype: + raise ValueError( + f"Value dtype {array.dtype.descr} is not the same as the " + f"structured dtype {structured_dtype}" + ) + + return array + + array_dtype = array_dtype_of(meta) + array = np.asarray(value).astype(array_dtype) + if array_dtype != array.dtype: + raise ValueError( + f"Value dtype {array.dtype} is not the same as the array dtype " + f"{array_dtype}" + ) + + shape = meta.get("shape", DEFAULT_ARRAY_SHAPE) + if len(shape) != len(array.shape) or any( + actual > maximum for actual, maximum in zip(array.shape, shape, strict=True) + ): + raise ValueError( + f"Value shape {array.shape} exceeeds the shape maximum shape {shape}" + ) + + return array diff --git a/src/fastcs/datatypes/waveform.py b/src/fastcs/datatypes/waveform.py deleted file mode 100644 index 8c09ce239..000000000 --- a/src/fastcs/datatypes/waveform.py +++ /dev/null @@ -1,46 +0,0 @@ -from dataclasses import dataclass - -import numpy as np -from numpy.typing import DTypeLike - -from fastcs.datatypes.datatype import DataType - - -@dataclass(frozen=True) -class Waveform(DataType[np.ndarray]): - array_dtype: DTypeLike - """Numpy array dtype""" - shape: tuple[int, ...] = (2000,) - """Numpy array shape""" - - @property - def dtype(self) -> type[np.ndarray]: - return np.ndarray - - @property - def initial_value(self) -> np.ndarray: - return np.zeros(self.shape, dtype=self.array_dtype) - - def validate(self, value: np.ndarray) -> np.ndarray: - _value = super().validate(np.asarray(value).astype(self.array_dtype)) - - if self.array_dtype != _value.dtype: - raise ValueError( - f"Value dtype {_value.dtype} is not the same as the array dtype " - f"{self.array_dtype}" - ) - - if len(self.shape) != len(_value.shape) or any( - shape1 > shape2 - for shape1, shape2 in zip(_value.shape, self.shape, strict=True) - ): - raise ValueError( - f"Value shape {_value.shape} exceeeds the shape maximum shape " - f"{self.shape}" - ) - - return _value - - @staticmethod - def equal(value1: np.ndarray, value2: np.ndarray) -> bool: - return np.array_equal(value1, value2) diff --git a/src/fastcs/demo/README.md b/src/fastcs/demo/README.md new file mode 100644 index 000000000..3865e7a2a --- /dev/null +++ b/src/fastcs/demo/README.md @@ -0,0 +1,76 @@ +# `fastcs.demo` + +The demo package ships FastCS's **living example controllers** for the +ophyd-async / FastCS API-convergence refactor +([issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), ADRs +0013–0019). Consolidated here (rather than a top-level `examples/` package) to +mirror ophyd-async, so the examples install with `fastcs[demo]` and can be run, +imported, and — crucially — used as the **single source of the tutorial code**. + +These modules are the canonical source the tutorials `literalinclude` from +(see the docs `tutorials/`). They are kept green under `uv run --locked tox`, +so the tutorials cannot drift from the framework: every framework PR that +changes an API updates the example(s) it affects in the *same* PR. This +replaces the old "hand-authored `docs/snippets/` that drift" approach — the +examples are the docs. + +## The example modules — a hello-world → complicated-device ladder + +Two hardware backends: a temperature-controller sim and a cut-down Eiger REST +sim. The hello-world is pure-soft (no backend). IO is supplied as plain +`getter`/`setter` callables on `AttrR`/`AttrW`/`AttrRW` (or the `@attr` +decorator) — there is no `io=` object and no `DataType`. + +| Module | Concept | Backend | Issue | +|--------|---------|---------|-------| +| `hello_world.py` | pure-soft `@attr` decorator over in-memory values | none (soft) | [#398](https://github.com/DiamondLightSource/fastcs/issues/398) | +| `temperature_attr.py` | `getter`/`setter` callables in `__init__` (`AttrRW(getter=…, setter=…)`), then composition & methods: sub-controllers / `ControllerVector`, `@scan`, `@command` | temperature sim | [#404](https://github.com/DiamondLightSource/fastcs/issues/404), [#390](https://github.com/DiamondLightSource/fastcs/issues/390) | +| `temperature_scpi.py` (+ `scpi.py`) | declarative annotated attributes; `ControllerFiller` builds each getter/setter from **static** `SCPIParam` extras metadata | temperature sim | [#405](https://github.com/DiamondLightSource/fastcs/issues/405) | +| `eiger.py` (+ `simulation/eiger.py`) | introspectable device: bare hints filled from a **runtime** REST parameter tree | Eiger REST sim | [#391](https://github.com/DiamondLightSource/fastcs/issues/391) | + +## The four tutorials + +Four modules, **four** tutorials (the old "reusable `io=` object" rung is gone — +`io=` objects were replaced by getter/setter callables, so there is nothing to +factor into): + +1. **hello world** — `hello_world.py` (soft `@attr`). +2. **getter/setter** — `temperature_attr.py`; the full multi-ramp temperature + controller, so this is also where **composition + `@scan` + `@command`** + are shown (#390). Closes with *"when the shared pattern is worth naming, + reach for the declarative style →"*. +3. **declarative** — `temperature_scpi.py` (annotated `SCPIParam` + filler). +4. **introspectable** — `eiger.py`. + +Notes: + +- **The declarative style is the DRY answer for a real protocol family**, not + a reusable IO object. Recommend it when the shared wire pattern is worth + naming (a protocol you'll reuse); for a handful of bespoke attributes, + getter/setter in `__init__` is lighter and fine. +- **`temperature_scpi.py` is deliberately *not* introspectable.** A SCPI device + does not describe itself, which is exactly why you hand-annotate: the metadata + lives in your Python (`SCPIParam("P", precision=3, …)`), not on the wire. Do + **not** invent SCPI introspection — that would erase the contrast with the + Eiger example. The `SCPIController`/`SCPIParam` vocabulary lives *here in the + demo*, not in core FastCS (decision 3: core ships no extras vocabulary for + 1.0); it demonstrates how a protocol layer builds on the filler's + `(child, extras)` mechanism. +- **`eiger.py` uses a separate REST backend on purpose.** Introspection earns + its complexity only when a device's parameters aren't knowable at author time + (a detector, not a fixed-command temp controller). The backend switch *is* + the lesson — "small & known → declare; large & self-describing → introspect" + — and the REST sim also exercises an HTTP client backend the temp examples + never touch, matching real downstream drivers (`fastcs-eiger`, `fastcs-secop`, + PandABlocks). + +## Baselines vs framework PRs + +`temperature_attr.py` and `eiger.py` have current-API baselines that can be written **now** (deliberately messy against the +pre-refactor API) and are cleaned up as each framework PR lands. `hello_world.py` +and `temperature_scpi.py` need framework work first (`@attr` #397; +`ControllerFiller` #394). See each issue's `Blocked by:` line. + +`literalinclude` region markers are added to each module as part of writing its +tutorial (the umbrella docs pass, +[#408](https://github.com/DiamondLightSource/fastcs/issues/408)), not up front. diff --git a/src/fastcs/demo/__main__.py b/src/fastcs/demo/__main__.py index ff4548063..467be4f81 100644 --- a/src/fastcs/demo/__main__.py +++ b/src/fastcs/demo/__main__.py @@ -1,6 +1,6 @@ from fastcs import __version__ from fastcs.launch import launch -from .controllers import TemperatureController +from .temperature_attr import TemperatureController launch(TemperatureController, version=__version__) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py deleted file mode 100755 index 5926fc8ce..000000000 --- a/src/fastcs/demo/controllers.py +++ /dev/null @@ -1,146 +0,0 @@ -import asyncio -import enum -import json -from dataclasses import KW_ONLY, dataclass -from typing import TypeVar - -import numpy as np - -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW -from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, Waveform -from fastcs.logging import logger -from fastcs.methods import command, scan - -NumberT = TypeVar("NumberT", int, float) - - -class OnOffEnum(enum.StrEnum): - Off = "0" - On = "1" - - -@dataclass -class TemperatureControllerSettings: - num_ramp_controllers: int - ip_settings: IPConnectionSettings - - -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): - def __init__(self, connection: IPConnection, suffix: str): - super().__init__() - - self._connection = connection - self.suffix = suffix - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self.suffix}={attr.dtype(value)}" - await self._connection.send_command(f"{command}\r\n") - self.log_event("Send command for attribute", topic=attr, command=command) - - async def update( - self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef] - ) -> None: - query = f"{attr.io_ref.name}{self.suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - response = response.strip("\r\n") - self.log_event( - "Query for attribute", - topic=attr, - query=query, - response=response, - ) - - await attr.update(attr.dtype(response)) - - -class TemperatureController(Controller): - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef(name="R")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef(name="P")) - voltages = AttrR(Waveform(np.int32, shape=(4,))) - - def __init__(self, settings: TemperatureControllerSettings) -> None: - self.connection = IPConnection() - self.suffix = "" - super().__init__( - ios=[TemperatureControllerAttributeIO(self.connection, self.suffix)] - ) - - self._settings = settings - - self._ramp_controllers: list[TemperatureRampController] = [] - for index in range(1, settings.num_ramp_controllers + 1): - controller = TemperatureRampController(index, self.connection) - self._ramp_controllers.append(controller) - self.add_sub_controller(f"R{index}", controller) - - @command() - async def cancel_all(self) -> None: - for rc in self._ramp_controllers: - await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True) - # TODO: The requests all get concatenated and the sim doesn't handle it - await asyncio.sleep(0.1) - - async def connect(self) -> None: - await self.connection.connect(self._settings.ip_settings) - - async def reconnect(self): - try: - await self.connection.close() - await self.connection.connect(self._settings.ip_settings) - except BaseException: - logger.exception("Reconnect failed") - return - - self._connected = True - - async def close(self) -> None: - await self.connection.close() - - @scan(0.1) - async def update_voltages(self): - query = "V?" - voltages = json.loads( - (await self.connection.send_query(f"{query}\r\n")).strip("\r\n") - ) - - await self.voltages.update(voltages) - - for index, controller in enumerate(self._ramp_controllers): - self.log_event( - "Update voltages", - topic=controller.voltage, - query=query, - response=voltages, - ) - await controller.voltage.update(float(voltages[index])) - - -class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW( - Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef(name="N") - ) - target = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="T")) - actual = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="A")) - voltage = AttrR(Float(prec=3)) - - def __init__(self, index: int, conn: IPConnection) -> None: - suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(conn, suffix)] - ) - self.connection = conn diff --git a/src/fastcs/demo/eiger.py b/src/fastcs/demo/eiger.py new file mode 100644 index 000000000..ba89d1f13 --- /dev/null +++ b/src/fastcs/demo/eiger.py @@ -0,0 +1,177 @@ +"""Example 5 - introspectable controller: a cut-down Eiger over the fake REST sim. + +Half the attributes (``count_time``, ``state``) are declared as type hints and +checked by the current ``HintedAttribute`` introspection-validation mechanism; the +rest of the parameter tree is discovered at ``initialise()`` time by walking the +sim's ``keys`` endpoints and is added dynamically, with no static check. A device +that describes itself over the wire is exactly the case where introspection earns +its complexity - contrast with the (deliberately non-introspectable) SCPI/temperature +examples. +""" + +import enum +from dataclasses import dataclass +from typing import Any, cast + +import httpx + +from fastcs.attributes import AttrR, AttrRW, Polled +from fastcs.controllers import Controller +from fastcs.datatypes import DType +from fastcs.demo.simulation.eiger import API_PREFIX, Subsystem, ValueType + +_DATATYPES: dict[ValueType, type[DType]] = { + "float": float, + "int": int, + "string": str, + "bool": bool, +} + +# Poll period (seconds) for read-only status params that change on the device. +UPDATE_PERIOD = 0.2 + + +def _datatype(param: str, data: dict[str, Any]) -> type[DType]: + """Build a datatype for a parameter from the metadata the device reports. + + A parameter that reports ``allowed_values`` is discrete, so it becomes an enum + class built from those values. The members are only knowable over the wire, + which is exactly the case introspection exists for. + """ + allowed_values = data.get("allowed_values") + if allowed_values is None: + return _DATATYPES[data["value_type"]] + + name = "".join(part.title() for part in param.split("_")) + # The functional API builds a class; type checkers only see the instance signature. + return cast( + type[enum.Enum], enum.Enum(name, {value: value for value in allowed_values}) + ) + + +@dataclass +class EigerConnectionSettings: + base_url: str = "http://localhost:8000" + + +class EigerConnection: + """Thin async HTTP client wrapper for the Eiger REST sim. + + A ``transport`` can be supplied to point directly at an in-process ASGI app + (e.g. in tests), bypassing the network entirely. + """ + + def __init__(self, transport: httpx.AsyncBaseTransport | None = None): + self._transport = transport + self._client: httpx.AsyncClient | None = None + + async def connect(self, settings: EigerConnectionSettings) -> None: + self._client = httpx.AsyncClient( + base_url=settings.base_url, transport=self._transport + ) + + async def close(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + @property + def client(self) -> httpx.AsyncClient: + if self._client is None: + raise RuntimeError("EigerConnection is not connected") + return self._client + + async def keys(self, subsystem: Subsystem) -> list[str]: + response = await self.client.get(f"{API_PREFIX}/{subsystem}/keys") + response.raise_for_status() + return response.json() + + async def get(self, subsystem: Subsystem, param: str) -> dict: + response = await self.client.get(f"{API_PREFIX}/{subsystem}/{param}") + response.raise_for_status() + return response.json() + + async def put(self, subsystem: Subsystem, param: str, value) -> None: + response = await self.client.put( + f"{API_PREFIX}/{subsystem}/{param}", json={"value": value} + ) + response.raise_for_status() + + +class EigerDetector(Controller): + """Cut-down Eiger controller: half declared, half introspected.""" + + # Declared (checked): must exist, with this access mode and dtype, after + # initialise() introspects the parameter tree. ``state`` is discrete, and its + # enum class is built from the ``allowed_values`` the device reports, so there + # is no author-time type to hint - only the access mode can be pinned here. + count_time: AttrRW[float] + state: AttrR + + # Derived (soft): built on top of the introspected ``state`` param. Declaring + # ``state`` as a checked attribute is what lets us reference it in code and + # publish something computed from it - here, whether the detector is idle. + idle = AttrR(bool) + + def __init__( + self, + settings: EigerConnectionSettings | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.connection = EigerConnection(transport=transport) + super().__init__() + + self._settings = settings or EigerConnectionSettings() + + def _getter(self, subsystem: Subsystem, param: str): + async def get() -> Any: + data = await self.connection.get(subsystem, param) + # No cast here - ``update`` validates against the datatype, which is the + # one place a bad value from the device should be coerced or complained + # about. + return data["value"] + + return get + + def _setter(self, subsystem: Subsystem, param: str): + async def put(value: Any) -> None: + await self.connection.put(subsystem, param, value) + + return put + + async def connect(self) -> None: + await self.connection.connect(self._settings) + self._connected = True + + async def disconnect(self) -> None: + await self.connection.close() + + async def initialise(self) -> None: + for subsystem in ("config", "status"): + for param in await self.connection.keys(subsystem): + data = await self.connection.get(subsystem, param) + datatype = _datatype(param, data) + + if data["access_mode"] == "rw": + attr = AttrRW( + datatype, + getter=self._getter(subsystem, param), + setter=self._setter(subsystem, param), + ) + else: + # Read-only params are status values that change on the device, + # so poll them periodically rather than reading once. + attr = AttrR( + datatype, + getter=Polled( + self._getter(subsystem, param), period=UPDATE_PERIOD + ), + ) + + self.add_attribute(param, attr) + + # Keep the derived ``idle`` flag in sync with the introspected ``state``. + self.state.add_readback_callback(self._update_idle) + + async def _update_idle(self, state: enum.Enum) -> None: + await self.idle.update(state.value == "idle") diff --git a/src/fastcs/demo/simulation/eiger.py b/src/fastcs/demo/simulation/eiger.py new file mode 100644 index 000000000..b70488d7e --- /dev/null +++ b/src/fastcs/demo/simulation/eiger.py @@ -0,0 +1,141 @@ +"""A cut-down, Eiger-shaped fake REST device for the introspectable controller demo. + +Mimics the shape of a real Eiger detector's parameter-tree REST API (subsystems of +named parameters, a ``keys`` listing endpoint, per-parameter GET/PUT) without any of +the real detector logic. Introspection earns its complexity only when a device's +parameters aren't knowable at author time - this sim exists to give that a genuine, +self-describing backend to introspect. +""" + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any, Literal + +from fastapi import FastAPI, HTTPException + +ValueType = Literal["float", "int", "string", "bool"] +AccessMode = Literal["r", "rw"] +Subsystem = Literal["config", "status"] + +API_PREFIX = "/detector/api/1.8.0" + +# The sim flips its temperature between these two values so the front end has +# something visibly changing to poll. +TEMPERATURES = (20.0, 30.0) + + +@dataclass +class EigerParameter: + value: Any + value_type: ValueType + access_mode: AccessMode = "r" + allowed_values: list[str] | None = None + """The permitted values of a discrete parameter, as the real detector reports them. + + Only discrete parameters carry this, and it is the metadata a client needs to + introspect the parameter as an enum rather than a bare string. + """ + + +def _initial_state() -> dict[Subsystem, dict[str, EigerParameter]]: + return { + "config": { + "count_time": EigerParameter(0.1, "float", "rw"), + "frame_time": EigerParameter(0.1, "float", "rw"), + "nimages": EigerParameter(1, "int", "rw"), + "description": EigerParameter("Simulated Eiger", "string", "r"), + }, + "status": { + "state": EigerParameter( + "idle", "string", "r", allowed_values=["idle", "ready", "acquire"] + ), + "temperature": EigerParameter(22.5, "float", "r"), + "humidity": EigerParameter(32.1, "float", "r"), + }, + } + + +async def _oscillate_temperature( + parameter: EigerParameter, period: float = 0.5 +) -> None: + """Flip a temperature parameter between two known values forever. + + Runs as a background task under the app's lifespan (started by a real server, + e.g. uvicorn). The in-process ASGI transport used by the controller in tests + does not start lifespan events, so a test that wants the task running drives + the lifespan explicitly. + """ + index = 0 + while True: + await asyncio.sleep(period) + index = 1 - index + parameter.value = TEMPERATURES[index] + + +def create_eiger_sim_app() -> FastAPI: + """Create a FastAPI app simulating a cut-down Eiger detector REST API.""" + state = _initial_state() + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + task = asyncio.create_task( + _oscillate_temperature(state["status"]["temperature"]) + ) + try: + yield + finally: + task.cancel() + + app = FastAPI(lifespan=lifespan) + # Backdoor: expose the parameter tree so tests can set read-only values (e.g. + # ``state``, which has no PUT route) and then poll them through the controller. + app.state.sim = state + + def _subsystem(subsystem: str) -> dict[str, EigerParameter]: + try: + return state[subsystem] # type: ignore[index] + except KeyError: + raise HTTPException( + status_code=404, detail=f"Unknown subsystem '{subsystem}'" + ) from None + + def _parameter(subsystem: str, param: str) -> EigerParameter: + try: + return _subsystem(subsystem)[param] + except KeyError: + raise HTTPException( + status_code=404, detail=f"Unknown parameter '{param}'" + ) from None + + @app.get(API_PREFIX + "/{subsystem}/keys") + async def get_keys(subsystem: str) -> list[str]: + return list(_subsystem(subsystem)) + + @app.get(API_PREFIX + "/{subsystem}/{param}") + async def get_parameter(subsystem: str, param: str) -> dict[str, Any]: + parameter = _parameter(subsystem, param) + data: dict[str, Any] = { + "value": parameter.value, + "value_type": parameter.value_type, + "access_mode": parameter.access_mode, + } + # Only discrete parameters report their options, as on the real detector. + if parameter.allowed_values is not None: + data["allowed_values"] = parameter.allowed_values + return data + + @app.put(API_PREFIX + "/{subsystem}/{param}") + async def put_parameter( + subsystem: str, param: str, body: dict[str, Any] + ) -> dict[str, Any]: + parameter = _parameter(subsystem, param) + if parameter.access_mode != "rw": + raise HTTPException( + status_code=403, detail=f"Parameter '{param}' is read-only" + ) + parameter.value = body["value"] + return {"value": parameter.value} + + return app diff --git a/src/fastcs/demo/temperature_attr.py b/src/fastcs/demo/temperature_attr.py new file mode 100755 index 000000000..89a7caabc --- /dev/null +++ b/src/fastcs/demo/temperature_attr.py @@ -0,0 +1,219 @@ +"""Example 2 - getter/setter: per-attribute IO wired via callables in ``__init__``. + +The device's protocol is written as a plain class with one ``async`` method per +command, each doing its own IO and returning a typed value - the shape a +manufacturer's own library usually already has. Those methods *are* the getters and +setters:: + + self.ramp_rate = AttrRW( + getter=Polled(protocol.get_ramp_rate, period=0.2), + setter=protocol.set_ramp_rate, + ) + +Nothing sits between the protocol and the attribute: no IO class hierarchy, no +per-attribute ref object, no adapter. Because each method annotates its types, the +datatype is inferred from them, so most attributes do not restate it - only the ones +that want metadata the annotation cannot carry, like ``precision=3``. + +Because the attributes are wired in ``__init__`` rather than the class body, each one +can close over per-instance state - which is what lets a ramp's index be baked into +its protocol instead of dispatched on at IO time. This module also carries the +composition and methods rungs: a ``ControllerVector`` of ``TemperatureRampController`` +sub-controllers, plus ``@scan`` and ``@command``. +""" + +import asyncio +import enum +import json +from collections.abc import Callable +from dataclasses import dataclass + +import numpy as np + +from fastcs.attributes import AttrR, AttrRW, Polled +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controllers import Controller, ControllerVector +from fastcs.datatypes import Array1D, DType_T +from fastcs.logging import logger +from fastcs.methods import command, scan + + +class OnOffEnum(enum.StrEnum): + Off = "0" + On = "1" + + +@dataclass +class TemperatureControllerSettings: + num_ramp_controllers: int + ip_settings: IPConnectionSettings + + +class TemperatureProtocol: + """The device's wire protocol - one async method per command, doing its own IO. + + This is the layer a manufacturer would ship: it knows how to talk to the device + and nothing about FastCS. Each method is a zero- or one-argument coroutine + returning an annotated type, which is exactly what an attribute's ``getter`` and + ``setter`` are, so they can be handed over as-is. + """ + + def __init__(self, connection: IPConnection, suffix: str = "") -> None: + self._connection = connection + self._suffix = suffix + + async def _query(self, param: str, dtype: Callable[[str], DType_T]) -> DType_T: + query = f"{param}{self._suffix}?\r\n" + response = (await self._connection.send_query(query)).strip("\r\n") + logger.trace("Query for attribute", query=query, response=response) + return dtype(response) + + async def _command(self, param: str, value: object) -> None: + command = f"{param}{self._suffix}={value}\r\n" + await self._connection.send_command(command) + logger.trace("Send command for attribute", command=command) + + async def get_ramp_rate(self) -> float: + return await self._query("R", float) + + async def set_ramp_rate(self, value: float) -> None: + await self._command("R", value) + + async def get_power(self) -> float: + return await self._query("P", float) + + async def get_voltages(self) -> np.ndarray: + query = "V?\r\n" + response = (await self._connection.send_query(query)).strip("\r\n") + logger.trace("Query for attribute", query=query, response=response) + return np.array(json.loads(response), dtype=np.int32) + + +class TemperatureRampProtocol(TemperatureProtocol): + """The protocol of a single ramp, whose commands are suffixed by its index. + + The index is baked into the instance, so every command is still a zero- or + one-argument callable that can be handed to an attribute as-is - no dispatching + on which ramp is being addressed at IO time. + """ + + def __init__(self, connection: IPConnection, index: int) -> None: + super().__init__(connection, suffix=f"{index:02d}") + + async def get_start(self) -> int: + return await self._query("S", int) + + async def set_start(self, value: int) -> None: + await self._command("S", value) + + async def get_end(self) -> int: + return await self._query("E", int) + + async def set_end(self, value: int) -> None: + await self._command("E", value) + + async def get_enabled(self) -> OnOffEnum: + return await self._query("N", OnOffEnum) + + async def set_enabled(self, value: OnOffEnum) -> None: + await self._command("N", value) + + async def get_target(self) -> float: + return await self._query("T", float) + + async def get_actual(self) -> float: + return await self._query("A", float) + + +class TemperatureController(Controller): + def __init__(self, settings: TemperatureControllerSettings) -> None: + self.connection = IPConnection() + self._settings = settings + self._protocol = TemperatureProtocol(self.connection) + + super().__init__() + + # No datatype: inferred from get_ramp_rate's `-> float` annotation. + self.ramp_rate = AttrRW( + getter=Polled(self._protocol.get_ramp_rate, period=0.2), + setter=self._protocol.set_ramp_rate, + ) + self.power = AttrR(getter=Polled(self._protocol.get_power, period=0.2)) + # Updated by the update_voltages scan below, so no IO of its own + self.voltages = AttrR(Array1D[np.int32], shape=(4,)) + + self.ramps = ControllerVector( + { + index: TemperatureRampController(index, self.connection) + for index in range(1, settings.num_ramp_controllers + 1) + } + ) + + @command() + async def cancel_all(self) -> None: + for rc in self.ramps.values(): + await rc.enabled.set(OnOffEnum.Off) + # TODO: The requests all get concatenated and the sim doesn't handle it + await asyncio.sleep(0.1) + + async def connect(self) -> None: + await self.connection.connect(self._settings.ip_settings) + + async def reconnect(self): + try: + await self.connection.close() + await self.connection.connect(self._settings.ip_settings) + except BaseException: + logger.exception("Reconnect failed") + return + + self._connected = True + + async def close(self) -> None: + await self.connection.close() + + @scan(0.1) + async def update_voltages(self): + voltages = await self._protocol.get_voltages() + + await self.voltages.update(voltages) + + for index, controller in self.ramps.items(): + self.log_event( + "Update voltages", topic=controller.voltage, response=voltages + ) + await controller.voltage.update(float(voltages[index - 1])) + + +class TemperatureRampController(Controller): + def __init__(self, index: int, conn: IPConnection) -> None: + self._protocol = TemperatureRampProtocol(conn, index) + + super().__init__(f"Ramp{index:02d}") + + self.connection = conn + + # Datatypes inferred from the protocol methods' annotations - including the + # enum, whose members come from OnOffEnum via get_enabled's return type. + self.start = AttrRW( + getter=Polled(self._protocol.get_start, period=0.2), + setter=self._protocol.set_start, + ) + self.end = AttrRW( + getter=Polled(self._protocol.get_end, period=0.2), + setter=self._protocol.set_end, + ) + self.enabled = AttrRW( + getter=Polled(self._protocol.get_enabled, period=0.2), + setter=self._protocol.set_enabled, + ) + # Stated explicitly, to carry metadata the annotation cannot: `-> float` + # says nothing about display precision. + self.target = AttrR( + float, precision=3, getter=Polled(self._protocol.get_target, period=0.2) + ) + self.actual = AttrR( + float, precision=3, getter=Polled(self._protocol.get_actual, period=0.2) + ) + # Updated by the parent controller's update_voltages scan + self.voltage = AttrR(float, precision=3) diff --git a/src/fastcs/methods/__init__.py b/src/fastcs/methods/__init__.py index 0cdeb616a..2136bbc80 100644 --- a/src/fastcs/methods/__init__.py +++ b/src/fastcs/methods/__init__.py @@ -1,3 +1,4 @@ +from .command import COMMAND_DTYPES as COMMAND_DTYPES from .command import Command as Command from .command import CommandCallback as CommandCallback from .command import UnboundCommand as UnboundCommand diff --git a/src/fastcs/methods/command.py b/src/fastcs/methods/command.py index 6818d7137..811e37238 100644 --- a/src/fastcs/methods/command.py +++ b/src/fastcs/methods/command.py @@ -1,7 +1,10 @@ -from collections.abc import Callable, Coroutine +import enum +from collections.abc import Callable, Coroutine, Sequence +from inspect import Parameter, Signature from types import MethodType -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Concatenate, Generic, ParamSpec, TypeVar +from fastcs.datatypes import DType from fastcs.logging import logger from fastcs.methods.method import Controller_T, Method @@ -9,37 +12,161 @@ from fastcs.controllers import BaseController # noqa: F401 -UnboundCommandCallback = Callable[[Controller_T], Coroutine[None, None, None]] +P = ParamSpec("P") +"""The parameters a `Command` takes""" +T = TypeVar("T") +"""The value a `Command` returns""" + +UnboundCommandCallback = Callable[ + Concatenate[Controller_T, P], Coroutine[None, None, T] +] """A Command callback that is unbound and must be called with a `Controller` instance""" -CommandCallback = Callable[[], Coroutine[None, None, None]] +CommandCallback = Callable[P, Coroutine[None, None, T]] """A Command callback that is bound and can be called without `self`""" -class Command(Method["BaseController"]): +COMMAND_DTYPES: tuple[type, ...] = (bool, int, float, str, enum.Enum) +"""The types a command argument or return value may have. + +A subset of ``DType``: arrays and tables are deliberately left out. Serving them +would mean duplicating the array serialisation each transport already has for +attributes rather than sharing it, which ADR 0015 explicitly does not want, and +an array-valued command has an attribute-shaped alternative today. +""" + + +def _validate_datatype(annotation: Any) -> type[DType]: + """Check that an annotation is a type a command can take or return. + + Args: + annotation: The annotation of a command parameter or return value + + Returns: + The annotation, once it is known to be a type a command can carry + + Raises: + TypeError: If the annotation is missing, or is not one of + `COMMAND_DTYPES`. The message describes the annotation alone - + the caller catches it to say which argument or return value it + came from. + + """ + if annotation is Signature.empty: + raise TypeError( + "has no type annotation. A command's argument and return types " + "must be fully known" + ) + + if not (isinstance(annotation, type) and issubclass(annotation, COMMAND_DTYPES)): + raise TypeError( + f"has unsupported type {annotation!r}. Commands take and return " + f"{', '.join(t.__name__ for t in COMMAND_DTYPES)}" + ) + + return annotation + + +def _validate_arguments( + parameters: Sequence[Parameter], fn: Callable +) -> tuple[type[DType], ...]: + """Check a command's parameters and collect their types. + + Args: + parameters: The parameters that are the command's arguments. An unbound + method still declares ``self``, so its caller drops the leading one + fn: The wrapped function, to name it in errors + + Returns: + The type of each argument, in order + + Raises: + TypeError: If a parameter is not a positional argument of a known type + + """ + argument_types = [] + for parameter in parameters: + if parameter.kind is Parameter.KEYWORD_ONLY: + raise TypeError( + f"Command {fn.__qualname__} has keyword-only argument " + f"'{parameter.name}'. Command arguments are positional; " + "keyword arguments are not supported yet" + ) + if parameter.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD): + raise TypeError( + f"Command {fn.__qualname__} takes *args or **kwargs. A " + "command's arguments must be fully known" + ) + + try: + argument_types.append(_validate_datatype(parameter.annotation)) + except TypeError as error: + raise TypeError( + f"Argument '{parameter.name}' of command {fn.__qualname__} {error}" + ) from error + + return tuple(argument_types) + + +def _validate_return(signature: Signature, fn: Callable) -> type[DType] | None: + annotation = signature.return_annotation + if annotation in (None, Signature.empty): + return None + + try: + return _validate_datatype(annotation) + except TypeError as error: + raise TypeError(f"Return value of command {fn.__qualname__} {error}") from error + + +class Command(Method["BaseController"], Generic[P, T]): """A `Controller` `Method` that performs a single action when called. + A command may take positional arguments and return a value, both of known + types - ``Command[[float], None]`` moves to a position, ``Command[[], None]`` + is the void case. What it takes and gives back is its ``signature``, which + is what a transport reads to decide how - or whether - to serve it. + This class contains a function that is bound to a specific `Controller` instance and is callable outside of the class context, without an explicit `self` parameter. Calling an instance of this class will call the bound `Controller` method. """ - def __init__(self, fn: CommandCallback, *, group: str | None = None): + def __init__(self, fn: CommandCallback[P, T], *, group: str | None = None): super().__init__(fn, group=group) - def _validate(self, fn: CommandCallback) -> None: + def _validate(self, fn: CommandCallback[P, T]) -> None: super()._validate(fn) - if not len(self.parameters) == 0: - raise TypeError(f"Command method cannot have arguments: {fn}") + self._argument_types = _validate_arguments(list(self.parameters.values()), fn) + self._return_datatype = _validate_return(self.signature, fn) + + @property + def argument_types(self) -> tuple[type[DType], ...]: + """The type of each positional argument the command takes.""" + return self._argument_types - async def __call__(self): - return await self.fn() + @property + def return_datatype(self) -> type[DType] | None: + """The type the command returns, or ``None`` if it returns nothing.""" + return self._return_datatype @property - def fn(self) -> CommandCallback: - async def command(): + def is_void(self) -> bool: + """Whether the command takes no arguments and returns nothing. + + A void command can be served by any transport; a typed one needs a + protocol that can carry a typed call. + """ + return not self._argument_types and self._return_datatype is None + + async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: + return await self.fn(*args, **kwargs) + + @property + def fn(self) -> CommandCallback[P, T]: + async def command(*args: P.args, **kwargs: P.kwargs) -> T: try: - return await self._fn() + return await self._fn(*args, **kwargs) except Exception: logger.exception("Command failed", fn=self._fn) raise @@ -47,7 +174,7 @@ async def command(): return command -class UnboundCommand(Method[Controller_T]): +class UnboundCommand(Method[Controller_T], Generic[Controller_T, P, T]): """A wrapper of an unbound `Controller` method to be bound into a `Command`. This generic class stores an unbound `Controller` method - effectively a function @@ -59,24 +186,33 @@ class UnboundCommand(Method[Controller_T]): """ def __init__( - self, fn: UnboundCommandCallback[Controller_T], *, group: str | None = None + self, + fn: UnboundCommandCallback[Controller_T, P, T], + *, + group: str | None = None, ) -> None: super().__init__(fn, group=group) - def _validate(self, fn: UnboundCommandCallback[Controller_T]) -> None: + def _validate(self, fn: UnboundCommandCallback[Controller_T, P, T]) -> None: super()._validate(fn) - if not len(self.parameters) == 1: - raise TypeError("Command method cannot have arguments") + if not self.parameters: + raise TypeError(f"Command {fn.__qualname__} must be a method, taking self") + + # The leading parameter is the ``Controller`` this is bound to, not an + # argument of the command. + _validate_arguments(list(self.parameters.values())[1:], fn) + _validate_return(self.signature, fn) - def bind(self, controller: Controller_T) -> Command: + def bind(self, controller: Controller_T) -> Command[P, T]: return Command(MethodType(self.fn, controller), group=self.group) def command( *, group: str | None = None ) -> Callable[ - [UnboundCommandCallback[Controller_T]], UnboundCommandCallback[Controller_T] + [UnboundCommandCallback[Controller_T, P, T]], + UnboundCommandCallback[Controller_T, P, T], ]: """Decorator to register a `Controller` method as a `Command` @@ -88,8 +224,8 @@ def command( """ def wrapper( - fn: UnboundCommandCallback[Controller_T], - ) -> UnboundCommandCallback[Controller_T]: + fn: UnboundCommandCallback[Controller_T, P, T], + ) -> UnboundCommandCallback[Controller_T, P, T]: setattr(fn, "__unbound_command__", UnboundCommand(fn, group=group)) # noqa: B010 return fn diff --git a/src/fastcs/methods/method.py b/src/fastcs/methods/method.py index f475256e3..5843b12db 100644 --- a/src/fastcs/methods/method.py +++ b/src/fastcs/methods/method.py @@ -1,12 +1,12 @@ from asyncio import iscoroutinefunction from collections.abc import Callable, Coroutine from inspect import Signature, getdoc, signature -from typing import Generic +from typing import Any, Generic from fastcs.tracer import Tracer from fastcs.util import Controller_T -MethodCallback = Callable[..., Coroutine[None, None, None]] +MethodCallback = Callable[..., Coroutine[None, None, Any]] """Generic protocol for all `Controller` Method callbacks""" @@ -17,10 +17,7 @@ def __init__(self, fn: MethodCallback, *, group: str | None = None) -> None: super().__init__() self._docstring = getdoc(fn) - - sig = signature(fn, eval_str=True) - self._parameters = sig.parameters - self._return_type = sig.return_annotation + self._signature = signature(fn, eval_str=True) self._validate(fn) self._fn = fn @@ -28,19 +25,54 @@ def __init__(self, fn: MethodCallback, *, group: str | None = None) -> None: self.enabled = True def _validate(self, fn: MethodCallback) -> None: - if self.return_type not in (None, Signature.empty): - raise TypeError("Method return type must be None or empty") - if not iscoroutinefunction(fn): raise TypeError("Method must be async function") + def _validate_takes_no_arguments(self, expected: int) -> None: + """Reject a method that takes anything beyond its bound ``self``. + + Args: + expected: How many parameters a no-argument method has here - one + for an unbound method, which still declares ``self`` + + Raises: + TypeError: If the method takes arguments. The message describes + the fault alone - the caller catches it to say what kind of + method it was. + + """ + if len(self.parameters) != expected: + raise TypeError("method cannot have arguments") + + def _validate_returns_nothing(self) -> None: + """Reject a method that declares a return type. + + Raises: + TypeError: If the method returns something. The message describes + the fault alone - the caller catches it to say what kind of + method it was. + + """ + if self.return_type not in (None, Signature.empty): + raise TypeError("method return type must be None or empty") + + @property + def signature(self) -> Signature: + """The signature of the wrapped function. + + This is the public description of how to call the method, and what it + gives back - transports read it to decide how to expose the method, and + whether they can expose it at all. + """ + return self._signature + @property def return_type(self): - return self._return_type + return self._signature.return_annotation @property def parameters(self): - return self._parameters + return self._signature.parameters @property def docstring(self): diff --git a/src/fastcs/methods/scan.py b/src/fastcs/methods/scan.py index c995490a4..9630765e3 100644 --- a/src/fastcs/methods/scan.py +++ b/src/fastcs/methods/scan.py @@ -41,8 +41,11 @@ def period(self): def _validate(self, fn: ScanCallback) -> None: super()._validate(fn) - if not len(self.parameters) == 0: - raise TypeError("Scan method cannot have arguments") + try: + self._validate_takes_no_arguments(expected=0) + self._validate_returns_nothing() + except TypeError as error: + raise TypeError(f"Scan {error}") from error async def __call__(self): return await self._fn() @@ -82,8 +85,12 @@ def period(self): def _validate(self, fn: UnboundScanCallback[Controller_T]) -> None: super()._validate(fn) - if not len(self.parameters) == 1: - raise TypeError("Scan method cannot have arguments") + # The leading parameter is the ``Controller`` this is bound to. + try: + self._validate_takes_no_arguments(expected=1) + self._validate_returns_nothing() + except TypeError as error: + raise TypeError(f"Scan {error}") from error def bind(self, controller: Controller_T) -> Scan: return Scan(MethodType(self.fn, controller), self._period) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 536cdcfa5..ddffc3cb8 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -2,13 +2,14 @@ from collections import Counter from typing import Any, Literal +import numpy as np from softioc import builder, softioc from softioc.asyncio_dispatcher import AsyncioDispatcher from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import DType_T, Waveform +from fastcs.datatypes import DEFAULT_ARRAY_SHAPE, DType_T from fastcs.logging import logger from fastcs.methods import Command from fastcs.tracer import Tracer @@ -126,11 +127,11 @@ def _create_and_link_attribute_pvs( for attr_name, attribute in controller_api.attributes.items(): if ( - isinstance(attribute.datatype, Waveform) - and len(attribute.datatype.shape) != 1 + issubclass(attribute.dtype, np.ndarray) + and len(attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE)) != 1 ): logger.warning( - "Only 1D Waveform attributes are supported in EPICS CA transport", + "Only 1D array attributes are supported in EPICS CA transport", attribute=attribute, ) continue @@ -206,7 +207,7 @@ async def async_record_set(value: DType_T): "PV set from attribute", topic=attribute, pv=pv, value=repr(value) ) - record.set(cast_to_epics_type(attribute.datatype, value)) + record.set(cast_to_epics_type(attribute, value)) record = _make_in_record(pv, attribute) @@ -214,7 +215,7 @@ async def async_record_set(value: DType_T): _add_attr_pvi_info(record, pv_prefix, attr_name, "r") - attribute.add_on_update_callback(async_record_set) + attribute.add_readback_callback(async_record_set) def _create_and_link_write_pv( @@ -229,14 +230,14 @@ def _create_and_link_write_pv( async def on_update(value): logger.info("PV put: {pv} = {value}", pv=pv, value=repr(value)) - await attribute.put(cast_from_epics_type(attribute.datatype, value)) + await attribute.set(cast_from_epics_type(attribute, value)) async def set_setpoint_without_process(value: DType_T): tracer.log_event( "PV setpoint set from attribute", topic=attribute, pv=pv, value=repr(value) ) - record.set(cast_to_epics_type(attribute.datatype, value), process=False) + record.set(cast_to_epics_type(attribute, value), process=False) record = _make_out_record(pv, attribute, on_update=on_update) @@ -244,7 +245,10 @@ async def set_setpoint_without_process(value: DType_T): _add_attr_pvi_info(record, pv_prefix, attr_name, "w") - attribute.add_sync_setpoint_callback(set_setpoint_without_process) + # Mirror the attribute's setpoint whenever it changes, however it changed - + # a put on this PV, a put on another transport, or the device reporting its + # own setpoint. See ADR 0020. + attribute.add_setpoint_callback(set_setpoint_without_process) def _create_and_link_command_pvs( @@ -254,6 +258,20 @@ def _create_and_link_command_pvs( pv_prefix = pv_prefix_from_path(controller_api.path) for attr_name, method in controller_api.command_methods.items(): + if not method.is_void: + # A PV is a value, not a call: there is no representation of + # "call with these arguments, get this back" that is not already + # a set of attributes. Skip rather than refuse to serve the + # controller at all (ADR 0015). + logger.warning( + "EPICS CA transport cannot serve a command that takes " + "arguments or returns a value", + command=attr_name, + signature=str(method.signature), + ) + method.enabled = False + continue + pv_name = snake_to_pascal(attr_name) alias = aliases.get(f"{pv_prefix}:{pv_name}", None) diff --git a/src/fastcs/transports/epics/ca/util.py b/src/fastcs/transports/epics/ca/util.py index 6a3e6dd83..374108a39 100644 --- a/src/fastcs/transports/epics/ca/util.py +++ b/src/fastcs/transports/epics/ca/util.py @@ -1,15 +1,22 @@ import enum import re from collections.abc import Callable -from dataclasses import asdict -from typing import Any +from typing import Any, cast +import numpy as np from softioc import builder from softioc.pythonSoftIoc import RecordWrapper -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform +from fastcs.datatypes import ( + DEFAULT_ARRAY_SHAPE, + DEFAULT_PRECISION, + DType, + DType_T, + Meta, + NumericLimits, +) from fastcs.exceptions import FastCSError from fastcs.transports.epics.util import validate_epics_pv_id @@ -50,226 +57,234 @@ def validate_ca_id(controller_api: ControllerAPI) -> None: MBB_MAX_CHOICES = len(_MBB_FIELD_PREFIXES) -EPICS_ALLOWED_DATATYPES = (Bool, Enum, Float, Int, String, Waveform) DEFAULT_STRING_WAVEFORM_LENGTH = 256 -DATATYPE_FIELD_TO_IN_RECORD_FIELD = { - "prec": "PREC", - "units": "EGU", - "min_alarm": "LOPR", - "max_alarm": "HOPR", -} -DATATYPE_FIELD_TO_OUT_RECORD_FIELD = { - "prec": "PREC", - "units": "EGU", - "min": "DRVL", - "max": "DRVH", - "min_alarm": "LOPR", - "max_alarm": "HOPR", -} +def is_epics_supported(dtype: type[DType]) -> bool: + """Whether EPICS CA can serve an attribute of this datatype.""" + return ( + dtype in (bool, int, float, str) + or issubclass(dtype, enum.Enum) + or issubclass(dtype, np.ndarray) + ) + + +def enum_names(dtype: type[enum.Enum]) -> list[str]: + """The names of an enum's members, in declaration order.""" + return [member.name for member in dtype] + + +def _display_limit_fields(meta: Meta) -> dict[str, Any]: + """The record fields for the range an attribute is displayed over.""" + limits: NumericLimits | None = meta.get("limits") + display = limits.display if limits is not None else None + + return { + "LOPR": display.low if display is not None else None, + "HOPR": display.high if display is not None else None, + } + + +def _control_limit_fields(meta: Meta) -> dict[str, Any]: + """The record fields for the range an attribute may be driven to.""" + limits: NumericLimits | None = meta.get("limits") + control = limits.control if limits is not None else None + + return { + "DRVL": control.low if control is not None else None, + "DRVH": control.high if control is not None else None, + } + + +def _string_length(meta: Meta) -> int: + return (meta.get("length") or DEFAULT_STRING_WAVEFORM_LENGTH) + 1 + + +def _array_length(meta: Meta) -> int: + return meta.get("shape", DEFAULT_ARRAY_SHAPE)[0] def _make_in_record(pv: str, attribute: AttrR) -> RecordWrapper: + meta = attribute.meta + dtype = attribute.dtype common_fields = { "DESC": attribute.description, - "initial_value": cast_to_epics_type(attribute.datatype, attribute.get()), + "initial_value": cast_to_epics_type(attribute, attribute.readback), } - match attribute.datatype: - case Bool(): - record = builder.boolIn(pv, ZNAM="False", ONAM="True", **common_fields) - case Int(): - record = builder.longIn( - pv, - LOPR=attribute.datatype.min_alarm, - HOPR=attribute.datatype.max_alarm, - EGU=attribute.datatype.units, - **common_fields, - ) - case Float(): - record = builder.aIn( - pv, - LOPR=attribute.datatype.min_alarm, - HOPR=attribute.datatype.max_alarm, - EGU=attribute.datatype.units, - PREC=attribute.datatype.prec, - **common_fields, - ) - case String(): - record = builder.longStringIn( - pv, - length=(attribute.datatype.length + 1) - if attribute.datatype.length - else DEFAULT_STRING_WAVEFORM_LENGTH + 1, - **common_fields, - ) - case Enum(): - if len(attribute.datatype.members) > MBB_MAX_CHOICES: - record = builder.longStringIn( - pv, - **common_fields, - ) - else: - common_fields.update(create_state_keys(attribute.datatype)) - record = builder.mbbIn( - pv, - **common_fields, - ) - case Waveform(): - record = builder.WaveformIn( - pv, length=attribute.datatype.shape[0], **common_fields - ) - case _: - raise FastCSError( - f"EPICS unsupported datatype on {attribute}: {attribute.datatype}" - ) - - def datatype_updater(datatype: DataType): - for name, value in asdict(datatype).items(): - if name in DATATYPE_FIELD_TO_IN_RECORD_FIELD: - record.set_field(DATATYPE_FIELD_TO_IN_RECORD_FIELD[name], value) - - attribute.add_update_datatype_callback(datatype_updater) + if dtype is bool: + record = builder.boolIn(pv, ZNAM="False", ONAM="True", **common_fields) + elif dtype is int: + record = builder.longIn( + pv, + EGU=meta.get("units"), + **_display_limit_fields(meta), + **common_fields, + ) + elif dtype is float: + record = builder.aIn( + pv, + EGU=meta.get("units"), + PREC=meta.get("precision", DEFAULT_PRECISION), + **_display_limit_fields(meta), + **common_fields, + ) + elif dtype is str: + record = builder.longStringIn(pv, length=_string_length(meta), **common_fields) + elif issubclass(dtype, enum.Enum): + if len(enum_names(dtype)) > MBB_MAX_CHOICES: + record = builder.longStringIn(pv, **common_fields) + else: + common_fields.update(create_state_keys(dtype)) + record = builder.mbbIn(pv, **common_fields) + elif issubclass(dtype, np.ndarray): + record = builder.WaveformIn(pv, length=_array_length(meta), **common_fields) + else: + raise FastCSError(f"EPICS unsupported datatype on {attribute}: {dtype}") + + _mirror_meta_onto_record(attribute, record, _in_record_fields) return record def _make_out_record(pv: str, attribute: AttrW, on_update: Callable) -> RecordWrapper: + meta = attribute.meta + dtype = attribute.dtype common_fields = { "DESC": attribute.description, "initial_value": cast_to_epics_type( - attribute.datatype, - attribute.get() + attribute, + attribute.readback if isinstance(attribute, AttrRW) - else attribute.datatype.initial_value, + else attribute.default_value(), ), "on_update": on_update, "always_update": True, "blocking": True, } - match attribute.datatype: - case Bool(): - record = builder.boolOut(pv, ZNAM="False", ONAM="True", **common_fields) - case Int(): - record = builder.longOut( - pv, - LOPR=attribute.datatype.min_alarm, - HOPR=attribute.datatype.max_alarm, - EGU=attribute.datatype.units, - DRVL=attribute.datatype.min, - DRVH=attribute.datatype.max, - **common_fields, - ) - case Float(): - record = builder.aOut( - pv, - LOPR=attribute.datatype.min_alarm, - HOPR=attribute.datatype.max_alarm, - EGU=attribute.datatype.units, - PREC=attribute.datatype.prec, - DRVL=attribute.datatype.min, - DRVH=attribute.datatype.max, - **common_fields, - ) - case String(): + if dtype is bool: + record = builder.boolOut(pv, ZNAM="False", ONAM="True", **common_fields) + elif dtype is int: + record = builder.longOut( + pv, + EGU=meta.get("units"), + **_display_limit_fields(meta), + **_control_limit_fields(meta), + **common_fields, + ) + elif dtype is float: + record = builder.aOut( + pv, + EGU=meta.get("units"), + PREC=meta.get("precision", DEFAULT_PRECISION), + **_display_limit_fields(meta), + **_control_limit_fields(meta), + **common_fields, + ) + elif dtype is str: + record = builder.longStringOut(pv, length=_string_length(meta), **common_fields) + elif issubclass(dtype, enum.Enum): + names = enum_names(dtype) + if len(names) > MBB_MAX_CHOICES: + + def _verify_in_names(_, value): + return value in names + record = builder.longStringOut( - pv, - length=(attribute.datatype.length + 1) - if attribute.datatype.length - else DEFAULT_STRING_WAVEFORM_LENGTH + 1, - **common_fields, - ) - case Enum(): - if len(attribute.datatype.members) > MBB_MAX_CHOICES: - datatype: Enum = attribute.datatype - - def _verify_in_datatype(_, value): - return value in datatype.names - - record = builder.longStringOut( - pv, - validate=_verify_in_datatype, - **common_fields, - ) - - else: - common_fields.update(create_state_keys(attribute.datatype)) - record = builder.mbbOut( - pv, - **common_fields, - ) - case Waveform(): - record = builder.WaveformOut( - pv, - length=attribute.datatype.shape[0], - **common_fields, - ) - case _: - raise FastCSError( - f"EPICS unsupported datatype on {attribute}: {attribute.datatype}" + pv, validate=_verify_in_names, **common_fields ) + else: + common_fields.update(create_state_keys(dtype)) + record = builder.mbbOut(pv, **common_fields) + elif issubclass(dtype, np.ndarray): + record = builder.WaveformOut(pv, length=_array_length(meta), **common_fields) + else: + raise FastCSError(f"EPICS unsupported datatype on {attribute}: {dtype}") + + _mirror_meta_onto_record(attribute, record, _out_record_fields) + return record - def datatype_updater(datatype: DataType): - for name, value in asdict(datatype).items(): - if name in DATATYPE_FIELD_TO_OUT_RECORD_FIELD: - record.set_field(DATATYPE_FIELD_TO_OUT_RECORD_FIELD[name], value) - attribute.add_update_datatype_callback(datatype_updater) - return record +def _in_record_fields(meta: Meta) -> dict[str, Any]: + return { + "PREC": meta.get("precision"), + "EGU": meta.get("units"), + **_display_limit_fields(meta), + } + + +def _out_record_fields(meta: Meta) -> dict[str, Any]: + return {**_in_record_fields(meta), **_control_limit_fields(meta)} + +def _mirror_meta_onto_record( + attribute: Attribute, + record: RecordWrapper, + fields_from_meta: Callable[[Meta], dict[str, Any]], +) -> None: + """Push later metadata changes - new units, say - onto the record.""" -def create_state_keys(datatype: Enum): + def meta_updater(meta: Meta) -> None: + for field, value in fields_from_meta(meta).items(): + if value is not None: + record.set_field(field, value) + + attribute.add_update_meta_callback(meta_updater) + + +def create_state_keys(dtype: type[enum.Enum]) -> dict[str, str]: """Creates a dictionary of state field keys to names""" return dict( zip( MBB_STATE_FIELDS, - datatype.names, + enum_names(dtype), strict=False, ) ) -def cast_from_epics_type(datatype: DataType[DType_T], value: object) -> DType_T: - """Casts from an EPICS datatype to a FastCS datatype.""" - match datatype: - case Bool(): - if value == 0: - return False - elif value == 1: - return True - else: - raise ValueError(f"Invalid bool value from EPICS record {value}") - case Enum(): - if len(datatype.members) <= MBB_MAX_CHOICES: - assert isinstance(value, int), "Got non-integer value for Enum" - return datatype.validate(datatype.members[value]) - else: # enum backed by string record - assert isinstance(value, str), "Got non-string value for long Enum" - # python typing can't narrow the nested generic enum_cls - assert issubclass(datatype.enum_cls, enum.Enum), "Invalid Enum.enum_cls" - enum_member = datatype.enum_cls[value] - return datatype.validate(enum_member) - case datatype if issubclass(type(datatype), EPICS_ALLOWED_DATATYPES): - return datatype.validate(value) # type: ignore - case _: - raise ValueError(f"Unsupported datatype {datatype}") - - -def cast_to_epics_type(datatype: DataType[DType_T], value: DType_T) -> Any: - """Casts from an attribute's datatype to an EPICS datatype.""" - match datatype: - case Enum(): - if len(datatype.members) <= MBB_MAX_CHOICES: - return datatype.index_of(datatype.validate(value)) - else: # enum backed by string record - return datatype.validate(value).name - case String() as string: - if string.length is not None: - return value[: string.length] - else: - return value[:DEFAULT_STRING_WAVEFORM_LENGTH] - case datatype if issubclass(type(datatype), EPICS_ALLOWED_DATATYPES): - return value - case _: - raise ValueError(f"Unsupported datatype {datatype}") +def cast_from_epics_type(attribute: Attribute[DType_T], value: object) -> DType_T: + """Casts from an EPICS value to an attribute's datatype.""" + dtype = attribute.dtype + + if dtype is bool: + if value == 0: + return False # pyright: ignore[reportReturnType] + elif value == 1: + return True # pyright: ignore[reportReturnType] + else: + raise ValueError(f"Invalid bool value from EPICS record {value}") + + if issubclass(dtype, enum.Enum): + if len(enum_names(dtype)) <= MBB_MAX_CHOICES: + assert isinstance(value, int), "Got non-integer value for Enum" + return attribute.validate(list(dtype)[value]) + # enum backed by string record + assert isinstance(value, str), "Got non-string value for long Enum" + return attribute.validate(dtype[value]) + + if is_epics_supported(dtype): + return attribute.validate(value) + + raise ValueError(f"Unsupported datatype {dtype}") + + +def cast_to_epics_type(attribute: Attribute[DType_T], value: DType_T) -> Any: + """Casts from an attribute's value to an EPICS value.""" + dtype = attribute.dtype + + if issubclass(dtype, enum.Enum): + member = cast(enum.Enum, attribute.validate(value)) + if len(enum_names(dtype)) <= MBB_MAX_CHOICES: + return list(dtype).index(member) + # enum backed by string record + return member.name + + if dtype is str: + length = attribute.meta.get("length") or DEFAULT_STRING_WAVEFORM_LENGTH + return str(value)[:length] + + if is_epics_supported(dtype): + return value + + raise ValueError(f"Unsupported datatype {dtype}") diff --git a/src/fastcs/transports/epics/gui.py b/src/fastcs/transports/epics/gui.py index 882a83a02..89cdf1282 100644 --- a/src/fastcs/transports/epics/gui.py +++ b/src/fastcs/transports/epics/gui.py @@ -1,3 +1,6 @@ +import enum + +import numpy as np from pvi.device import ( LED, ArrayTrace, @@ -24,14 +27,7 @@ from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import ( - Bool, - Enum, - Float, - Int, - String, - Waveform, -) +from fastcs.datatypes import DEFAULT_ARRAY_SHAPE, DEFAULT_PRECISION from fastcs.logging import logger from fastcs.methods import Command from fastcs.transports.epics.util import pv_prefix_from_path @@ -51,45 +47,50 @@ def _get_pv(self, attr_path: list[str], name: str): return f"{attr_prefix}:{snake_to_pascal(name)}" def _get_read_widget(self, attribute: Attribute) -> ReadWidgetUnion | None: - match attribute.datatype: - case Bool(): - return LED() - case Int(): - return TextRead(precision=0) - case Float(prec=precision): - return TextRead(precision=precision) - case String(): - return TextRead(format=TextFormat.string) - case Enum(): - return TextRead(format=TextFormat.string) - case Waveform() as waveform: - if len(waveform.shape) > 1: - logger.warning( - "EPICS CA transport only supports 1D waveforms, " - f"{attribute} is a {len(waveform.shape)}D waveform" - ) - return None + dtype = attribute.dtype + if dtype is bool: + return LED() + if dtype is int: + return TextRead(precision=0) + if dtype is float: + return TextRead( + precision=attribute.meta.get("precision", DEFAULT_PRECISION) + ) + if dtype is str: + return TextRead(format=TextFormat.string) + if issubclass(dtype, enum.Enum): + return TextRead(format=TextFormat.string) + if issubclass(dtype, np.ndarray): + shape = attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE) + if len(shape) > 1: + logger.warning( + "EPICS CA transport only supports 1D waveforms, " + f"{attribute} is a {len(shape)}D waveform" + ) + return None + + return ArrayTrace(axis="x") - return ArrayTrace(axis="x") - case datatype: - raise TypeError(f"Unsupported type {type(datatype)}: {datatype}") + raise TypeError(f"Unsupported type {dtype}") def _get_write_widget(self, attribute: Attribute) -> WriteWidgetUnion | None: - match attribute.datatype: - case Bool(): - return ToggleButton() - case Int(): - return TextWrite(precision=0) - case Float(prec=precision): - return TextWrite(precision=precision) - case String(): - return TextWrite(format=TextFormat.string) - case Enum(): - return ComboBox(choices=attribute.datatype.names) - case Waveform(): - return None - case datatype: - raise TypeError(f"Unsupported type {type(datatype)}: {datatype}") + dtype = attribute.dtype + if dtype is bool: + return ToggleButton() + if dtype is int: + return TextWrite(precision=0) + if dtype is float: + return TextWrite( + precision=attribute.meta.get("precision", DEFAULT_PRECISION) + ) + if dtype is str: + return TextWrite(format=TextFormat.string) + if issubclass(dtype, enum.Enum): + return ComboBox(choices=[member.name for member in dtype]) + if issubclass(dtype, np.ndarray): + return None + + raise TypeError(f"Unsupported type {dtype}") def _get_attribute_component( self, attr_path: list[str], name: str, attribute: Attribute @@ -163,6 +164,11 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree: groups: dict[str, list[ComponentUnion]] = {} for attr_name, attribute in controller_api.attributes.items(): + if not attribute.enabled: + # The IOC is built before the GUI, so anything it could not + # serve has already said so - don't draw a control for it. + continue + try: signal = self._get_attribute_component( controller_api.path, @@ -189,6 +195,9 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree: components.append(signal) for name, command in controller_api.command_methods.items(): + if not command.enabled: + continue + signal = self._get_command_component(controller_api.path, name) match command: diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index 5ba819f98..ea78820ef 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -1,3 +1,5 @@ +import enum + import numpy as np from p4p import Value from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable @@ -7,7 +9,6 @@ from p4p.server.asyncio import SharedPV from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.datatypes import Enum, Table from fastcs.methods import CommandCallback from fastcs.tracer import Tracer @@ -30,11 +31,12 @@ def __init__(self, attr_w: AttrW | AttrRW): async def put(self, pv: SharedPV, op: ServerOperation): value = op.value() - if isinstance(self._attr_w.datatype, Table): + structured_dtype = self._attr_w.meta.get("structured_dtype") + if structured_dtype is not None: assert isinstance(value, list) raw_value = np.array( [tuple(labelled_row.values()) for labelled_row in value], - dtype=self._attr_w.datatype.structured_dtype, + dtype=structured_dtype, ) elif isinstance(value, Value): raw_value = value.todict()["value"] @@ -48,12 +50,12 @@ async def put(self, pv: SharedPV, op: ServerOperation): tracer.log_event("PV put", topic=self._attr_w, pv=pv, value=cast_value) - if isinstance(self._attr_w.datatype, Enum): + if issubclass(self._attr_w.dtype, enum.Enum): pv.post(cast_to_p4p_value(self._attr_w, cast_value)) else: pv.post(value) - await self._attr_w.put(cast_value) + await self._attr_w.set(cast_value) op.done() @@ -121,7 +123,7 @@ def _wrap(value: dict): def make_shared_read_pv(attribute: AttrR) -> SharedPV: shared_pv = SharedPV( - initial=cast_to_p4p_value(attribute, attribute.get()), + initial=cast_to_p4p_value(attribute, attribute.readback), **_make_shared_pv_arguments(attribute), ) @@ -129,7 +131,7 @@ async def set_readback(value): tracer.log_event("PV set readback", topic=attribute, value=value) shared_pv.post(cast_to_p4p_value(attribute, value)) - attribute.add_on_update_callback(set_readback) + attribute.add_readback_callback(set_readback) return shared_pv @@ -137,7 +139,7 @@ async def set_readback(value): def make_shared_write_pv(attribute: AttrW) -> SharedPV: shared_pv = SharedPV( handler=WritePvHandler(attribute), - initial=cast_to_p4p_value(attribute, attribute.datatype.initial_value), + initial=cast_to_p4p_value(attribute, attribute.default_value()), **_make_shared_pv_arguments(attribute), ) @@ -145,7 +147,10 @@ async def set_setpoint(value): tracer.log_event("PV set setpoint", topic=attribute, value=value) shared_pv.post(cast_to_p4p_value(attribute, value)) - attribute.add_sync_setpoint_callback(set_setpoint) + # Mirror the attribute's setpoint whenever it changes, however it changed - a + # put on this PV, a put on another transport, or the device reporting its own + # setpoint. See ADR 0020. + attribute.add_setpoint_callback(set_setpoint) return shared_pv diff --git a/src/fastcs/transports/epics/pva/gui.py b/src/fastcs/transports/epics/pva/gui.py index 0ae6e4de1..5d25660d0 100644 --- a/src/fastcs/transports/epics/pva/gui.py +++ b/src/fastcs/transports/epics/pva/gui.py @@ -1,3 +1,4 @@ +import numpy as np from pvi.device import ( CheckBox, ImageColorMap, @@ -9,7 +10,10 @@ ) from fastcs.attributes import Attribute, AttrR, AttrW -from fastcs.datatypes import Bool, Table, Waveform, numpy_to_fastcs_datatype +from fastcs.datatypes import ( + DEFAULT_ARRAY_SHAPE, + numpy_to_python_type, +) from fastcs.transports.epics.gui import EpicsGUI @@ -22,39 +26,42 @@ def _get_pv(self, attr_path: list[str], name: str): return f"pva://{super()._get_pv(attr_path, name)}" def _get_read_widget(self, attribute: Attribute) -> ReadWidgetUnion | None: - match attribute.datatype: - case Table(): - fastcs_datatypes = [ - numpy_to_fastcs_datatype(datatype) - for _, datatype in attribute.datatype.structured_dtype - ] - - base_get_read_widget = super()._get_read_widget - widgets = [ - base_get_read_widget(AttrR(datatype)) - for datatype in fastcs_datatypes - ] - - return TableRead(widgets=widgets) # type: ignore - case Waveform(shape=(height, width)): + structured_dtype = attribute.meta.get("structured_dtype") + if structured_dtype is not None: + column_types = [ + numpy_to_python_type(column_dtype) + for _, column_dtype in structured_dtype + ] + + base_get_read_widget = super()._get_read_widget + widgets = [ + base_get_read_widget(AttrR(column_type)) for column_type in column_types + ] + + return TableRead(widgets=widgets) # type: ignore + + if issubclass(attribute.dtype, np.ndarray): + shape = attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE) + if len(shape) == 2: + height, width = shape return ImageRead( height=height, width=width, color_map=ImageColorMap.GRAY ) - case _: - return super()._get_read_widget(attribute) + + return super()._get_read_widget(attribute) def _get_write_widget(self, attribute: Attribute) -> WriteWidgetUnion | None: - match attribute.datatype: - case Table(): - widgets = [] - for _, datatype in attribute.datatype.structured_dtype: - fastcs_datatype = numpy_to_fastcs_datatype(datatype) - if isinstance(fastcs_datatype, Bool): - # Replace with compact version for Table row - widget = CheckBox() - else: - widget = super()._get_write_widget(AttrW(fastcs_datatype)) - widgets.append(widget) - return TableWrite(widgets=widgets) - case _: - return super()._get_write_widget(attribute) + structured_dtype = attribute.meta.get("structured_dtype") + if structured_dtype is not None: + widgets = [] + for _, column_dtype in structured_dtype: + column_type = numpy_to_python_type(column_dtype) + if column_type is bool: + # Replace with compact version for Table row + widget = CheckBox() + else: + widget = super()._get_write_widget(AttrW(column_type)) + widgets.append(widget) + return TableWrite(widgets=widgets) + + return super()._get_write_widget(attribute) diff --git a/src/fastcs/transports/epics/pva/ioc.py b/src/fastcs/transports/epics/pva/ioc.py index 5b2e29611..4bfb370d6 100644 --- a/src/fastcs/transports/epics/pva/ioc.py +++ b/src/fastcs/transports/epics/pva/ioc.py @@ -4,6 +4,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI +from fastcs.logging import logger from fastcs.transports.epics.util import pv_prefix_from_path from fastcs.util import snake_to_pascal @@ -11,7 +12,7 @@ from .pvi import add_pvi_info -async def parse_attributes(root_controller_api: ControllerAPI) -> StaticProvider: +def parse_attributes(root_controller_api: ControllerAPI) -> StaticProvider: """Parses `Attribute` s into p4p signals in handlers.""" provider = StaticProvider(pv_prefix_from_path(root_controller_api.path)) @@ -40,6 +41,18 @@ async def parse_attributes(root_controller_api: ControllerAPI) -> StaticProvider provider.add(f"{full_pv_name}", attribute_pv) for attr_name, method in controller_api.command_methods.items(): + if not method.is_void: + # As for CA: PVA has no typed-call representation either, so a + # typed command is skipped with a warning (ADR 0015). + logger.warning( + "EPICS PVA transport cannot serve a command that takes " + "arguments or returns a value", + command=attr_name, + signature=str(method.signature), + ) + method.enabled = False + continue + full_pv_name = f"{pv_prefix}:{snake_to_pascal(attr_name)}" command_pv = make_command_pv(method.fn) provider.add(f"{full_pv_name}", command_pv) @@ -56,13 +69,16 @@ class P4PIOC: def __init__(self, controller_apis: list[ControllerAPI]): self._controller_apis = controller_apis - - async def _build_providers(self) -> list[StaticProvider]: - return [await parse_attributes(api) for api in self._controller_apis] + # Build the PVs up front rather than in ``run``. Creating a PV is what + # registers its readback/setpoint callbacks on the attribute, and ``run`` + # is awaited after the initial polls have already fired - so a PV built + # there would miss the first readback, and the setpoint an ``AttrRW`` + # seeds from it (ADR 0020), leaving the served value at the datatype + # default. ``EpicsCAIOC`` builds its records in ``__init__`` for the + # same reason. + self._providers = [parse_attributes(api) for api in self._controller_apis] async def run(self): - providers = await self._build_providers() - endless_event = asyncio.Event() - with Server(providers): + with Server(self._providers): await endless_event.wait() diff --git a/src/fastcs/transports/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py index 75e979996..ea8accc0d 100644 --- a/src/fastcs/transports/epics/pva/types.py +++ b/src/fastcs/transports/epics/pva/types.py @@ -1,3 +1,4 @@ +import enum import math import time @@ -7,10 +8,14 @@ from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable from fastcs.attributes import Attribute, AttrR, AttrW -from fastcs.datatypes import Bool, DType, Enum, Float, Int, String, Table, Waveform -from fastcs.datatypes.datatype import DType_T - -P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform, Table) +from fastcs.datatypes import ( + DEFAULT_ARRAY_SHAPE, + DEFAULT_PRECISION, + DType, + DType_T, + Meta, + NumericLimits, +) # https://epics-base.github.io/pvxs/nt.html#alarm-t RECORD_ALARM_STATUS = 3 @@ -49,64 +54,78 @@ def _table_with_numpy_dtypes_to_p4p_dtypes(numpy_dtypes: list[tuple[str, DTypeLi return p4p_dtypes +def is_p4p_supported(dtype: type[DType]) -> bool: + """Whether the PVA transport can serve an attribute of this datatype.""" + return ( + dtype in (bool, int, float, str) + or issubclass(dtype, enum.Enum) + or issubclass(dtype, np.ndarray) + ) + + def make_p4p_type( attribute: Attribute, ) -> NTScalar | NTEnum | NTNDArray | NTTable: - """Creates a p4p type for a given `Attribute` `DataType`.""" + """Creates a p4p type for a given `Attribute` datatype.""" display = isinstance(attribute, AttrR) control = isinstance(attribute, AttrW) - match attribute.datatype: - case Int(): - return NTScalar.buildType("i", display=display, control=control) - case Float(): - return NTScalar.buildType("d", display=display, control=control, form=True) - case String(): - return NTScalar.buildType("s", display=display, control=control) - case Bool(): - return NTScalar.buildType("?", display=display, control=control) - case Enum(): - return NTEnum() - case Waveform(): - # TODO: https://github.com/DiamondLightSource/FastCS/issues/123 - # * Make 1D scalar array for 1D shapes. - # This will require converting from np.int32 to "ai" - # if len(shape) == 1: - # return NTScalarArray(convert np.datatype32 to string "ad") - # * Add an option for allowing shape to change, if so we will - # use an NDArray here even if shape is 1D - - return NTNDArray() - case Table(structured_dtype): + dtype = attribute.dtype + + if dtype is bool: + return NTScalar.buildType("?", display=display, control=control) + if dtype is int: + return NTScalar.buildType("i", display=display, control=control) + if dtype is float: + return NTScalar.buildType("d", display=display, control=control, form=True) + if dtype is str: + return NTScalar.buildType("s", display=display, control=control) + if issubclass(dtype, enum.Enum): + return NTEnum() + if issubclass(dtype, np.ndarray): + if (structured_dtype := attribute.meta.get("structured_dtype")) is not None: # TODO: `NTEnum/NTNDArray/NTTable.wrap` don't accept extra fields until # https://github.com/epics-base/p4p/issues/166 return NTTable( columns=_table_with_numpy_dtypes_to_p4p_dtypes(structured_dtype) ) - case _: - raise RuntimeError(f"DataType `{attribute.datatype}` unsupported in P4P.") + + # TODO: https://github.com/DiamondLightSource/FastCS/issues/123 + # * Make 1D scalar array for 1D shapes. + # This will require converting from np.int32 to "ai" + # if len(shape) == 1: + # return NTScalarArray(convert np.datatype32 to string "ad") + # * Add an option for allowing shape to change, if so we will + # use an NDArray here even if shape is 1D + + return NTNDArray() + + raise RuntimeError(f"Datatype `{dtype}` unsupported in P4P.") def cast_from_p4p_value(attribute: Attribute[DType_T], value: object) -> DType_T: """Converts from a p4p value to a FastCS `Attribute` value.""" - match attribute.datatype: - case Enum(): - assert hasattr(value, "index"), "Got non-enum p4p.Value for Enum DataType" - index: int = value.index # pyright: ignore[reportAttributeAccessIssue] - return attribute.datatype.validate(attribute.datatype.members[index]) - case Waveform(shape=shape): - # p4p sends a flattened array - assert value.shape == (math.prod(shape),) - return attribute.datatype.validate(value.reshape(attribute.datatype.shape)) - case Table(structured_dtype): + dtype = attribute.dtype + + if issubclass(dtype, enum.Enum): + assert hasattr(value, "index"), "Got non-enum p4p.Value for Enum datatype" + index: int = value.index # pyright: ignore[reportAttributeAccessIssue] + return attribute.validate(list(dtype)[index]) + + if issubclass(dtype, np.ndarray): + if (structured_dtype := attribute.meta.get("structured_dtype")) is not None: assert isinstance(value, np.ndarray) - return attribute.datatype.validate(np.array(value, dtype=structured_dtype)) - case attribute.datatype if issubclass( - type(attribute.datatype), P4P_ALLOWED_DATATYPES - ): - return attribute.datatype.validate(value) # type: ignore - case _: - raise ValueError(f"Unsupported datatype {attribute.datatype}") + return attribute.validate(np.array(value, dtype=structured_dtype)) + + shape = attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE) + # p4p sends a flattened array + assert value.shape == (math.prod(shape),) # pyright: ignore[reportAttributeAccessIssue] + return attribute.validate(value.reshape(shape)) # pyright: ignore[reportAttributeAccessIssue] + + if is_p4p_supported(dtype): + return attribute.validate(value) + + raise ValueError(f"Unsupported datatype {dtype}") def p4p_alarm_states( @@ -140,26 +159,33 @@ def p4p_timestamp_now() -> dict: def p4p_display(attribute: Attribute) -> dict: """Gets the p4p display structure for a given attribute.""" display = {} + meta = attribute.meta if attribute.description is not None: display["description"] = attribute.description - if isinstance(attribute.datatype, (Float | Int)): - if attribute.datatype.max is not None: - display["limitHigh"] = attribute.datatype.max - if attribute.datatype.min is not None: - display["limitLow"] = attribute.datatype.min - if attribute.datatype.units is not None: - display["units"] = attribute.datatype.units - if isinstance(attribute.datatype, Float): - if attribute.datatype.prec is not None: - display["precision"] = attribute.datatype.prec + if attribute.dtype in (int, float): + limits: NumericLimits | None = meta.get("limits") + if limits is not None: + if limits.control.high is not None: + display["limitHigh"] = limits.control.high + if limits.control.low is not None: + display["limitLow"] = limits.control.low + if (units := meta.get("units")) is not None: + display["units"] = units + if attribute.dtype is float: + display["precision"] = meta.get("precision", DEFAULT_PRECISION) if display: return {"display": display} return {} -def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) -> dict: - low = None if datatype.min_alarm is None else value < datatype.min_alarm # type: ignore - high = None if datatype.max_alarm is None else value > datatype.max_alarm # type: ignore +def _p4p_check_numeric_for_alarm_states(meta: Meta, value: DType) -> dict: + limits: NumericLimits | None = meta.get("limits") + alarm = limits.alarm if limits is not None else None + alarm_low = alarm.low if alarm is not None else None + alarm_high = alarm.high if alarm is not None else None + + low = None if alarm_low is None else value < alarm_low # type: ignore + high = None if alarm_high is None else value > alarm_high # type: ignore severity = ( MAJOR_ALARM_SEVERITY if high not in (None, False) or low not in (None, False) @@ -169,12 +195,12 @@ def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) -> if low: status, message = ( RECORD_ALARM_STATUS, - f"Below minimum alarm limit: {datatype.min_alarm}", + f"Below minimum alarm limit: {alarm_low}", ) if high: status, message = ( RECORD_ALARM_STATUS, - f"Above maximum alarm limit: {datatype.max_alarm}", + f"Above maximum alarm limit: {alarm_high}", ) return p4p_alarm_states(severity, status, message) @@ -182,34 +208,32 @@ def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) -> def cast_to_p4p_value(attribute: Attribute[DType_T], value: DType_T) -> object: """Converts a FastCS ``Attribute`` value to a p4p value""" - match attribute.datatype: - case Enum(): - return { - "index": attribute.datatype.index_of(value), - "choices": attribute.datatype.names, - } - case Waveform(): - return attribute.datatype.validate(value) - case Table(): - return attribute.datatype.validate(value) - - case datatype if issubclass(type(datatype), P4P_ALLOWED_DATATYPES): - record_fields: dict = {"value": datatype.validate(value)} - if isinstance(attribute, AttrR): - record_fields.update(p4p_display(attribute)) - - if isinstance(datatype, (Float | Int)): - record_fields.update( - _p4p_check_numeric_for_alarm_states( - datatype, - value, - ) - ) - else: - record_fields.update(p4p_alarm_states()) - - record_fields.update(p4p_timestamp_now()) - - return Value(make_p4p_type(attribute), record_fields) - case _: - raise ValueError(f"Unsupported datatype {attribute.datatype}") + dtype = attribute.dtype + + if issubclass(dtype, enum.Enum): + members = list(dtype) + return { + "index": members.index(value), # pyright: ignore[reportArgumentType] + "choices": [member.name for member in members], + } + + if issubclass(dtype, np.ndarray): + return attribute.validate(value) + + if is_p4p_supported(dtype): + record_fields: dict = {"value": attribute.validate(value)} + if isinstance(attribute, AttrR): + record_fields.update(p4p_display(attribute)) + + if dtype in (int, float): + record_fields.update( + _p4p_check_numeric_for_alarm_states(attribute.meta, value) + ) + else: + record_fields.update(p4p_alarm_states()) + + record_fields.update(p4p_timestamp_now()) + + return Value(make_p4p_type(attribute), record_fields) + + raise ValueError(f"Unsupported datatype {dtype}") diff --git a/src/fastcs/transports/graphql/graphql.py b/src/fastcs/transports/graphql/graphql.py index 871e5c905..299543111 100644 --- a/src/fastcs/transports/graphql/graphql.py +++ b/src/fastcs/transports/graphql/graphql.py @@ -1,4 +1,5 @@ from collections.abc import Awaitable, Callable, Coroutine +from inspect import Parameter, Signature from typing import Any import strawberry @@ -9,9 +10,10 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes.datatype import DType_T +from fastcs.datatypes import DType_T from fastcs.exceptions import FastCSError from fastcs.logging import intercept_std_logger +from fastcs.methods import Command from .options import GraphQLServerOptions @@ -112,7 +114,7 @@ def _process_attributes(self, api: ControllerAPI): def _process_commands(self, controller_api: ControllerAPI): """Create mutations from api commands""" for name, method in controller_api.command_methods.items(): - self.mutations.append(strawberry.mutation(_wrap_command(name, method.fn))) + self.mutations.append(strawberry.mutation(_wrap_command(name, method))) def _process_sub_apis(self, root_controller_api: ControllerAPI): """Recursively add fields from the queries and mutations of sub apis""" @@ -144,13 +146,13 @@ def _wrap_attr_set( """Wrap an attribute in a function with annotations for strawberry""" async def _dynamic_f(value): - await attribute.put(value) + await attribute.set(value) return value # Add type annotations for validation, schema, conversions _dynamic_f.__name__ = attr_name - _dynamic_f.__annotations__["value"] = attribute.datatype.dtype - _dynamic_f.__annotations__["return"] = attribute.datatype.dtype + _dynamic_f.__annotations__["value"] = attribute.dtype + _dynamic_f.__annotations__["return"] = attribute.dtype return _dynamic_f @@ -161,10 +163,10 @@ def _wrap_attr_get( """Wrap an attribute in a function with annotations for strawberry""" async def _dynamic_f() -> DType_T: - return attribute.get() + return attribute.readback _dynamic_f.__name__ = attr_name - _dynamic_f.__annotations__["return"] = attribute.datatype.dtype + _dynamic_f.__annotations__["return"] = attribute.dtype return _dynamic_f @@ -181,13 +183,35 @@ def _dynamic_field(): return strawberry.field(_dynamic_field) -def _wrap_command(method_name: str, method: Callable) -> Callable[..., Awaitable[bool]]: +def _wrap_command(method_name: str, command: Command) -> Callable[..., Awaitable[Any]]: """Wrap a command in a function with annotations for strawberry""" + argument_names = [ + parameter.name for parameter in command.signature.parameters.values() + ] + return_datatype = command.return_datatype + # A void command has no value to give back, so it reports that it ran. + return_annotation = bool if return_datatype is None else return_datatype - async def _dynamic_f() -> bool: - await method() - return True + async def _dynamic_f(**kwargs): + result = await command.fn(*(kwargs[name] for name in argument_names)) + return True if return_datatype is None else result _dynamic_f.__name__ = method_name + # Strawberry builds the mutation's arguments and result by introspecting the + # resolver, so the command's arguments have to show up in both the signature + # and the annotations of a function that does not literally declare them. + _dynamic_f.__signature__ = Signature( # type: ignore[attr-defined] + [ + Parameter(name, Parameter.POSITIONAL_OR_KEYWORD, annotation=argument_type) + for name, argument_type in zip( + argument_names, command.argument_types, strict=True + ) + ], + return_annotation=return_annotation, + ) + _dynamic_f.__annotations__ = dict( + zip(argument_names, command.argument_types, strict=True) + ) + _dynamic_f.__annotations__["return"] = return_annotation return _dynamic_f diff --git a/src/fastcs/transports/rest/rest.py b/src/fastcs/transports/rest/rest.py index 522246b2e..53e6bd2d4 100644 --- a/src/fastcs/transports/rest/rest.py +++ b/src/fastcs/transports/rest/rest.py @@ -3,13 +3,14 @@ import uvicorn from fastapi import FastAPI -from pydantic import create_model +from pydantic import BaseModel, create_model from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes.datatype import DType_T +from fastcs.datatypes import DType_T from fastcs.logging import intercept_std_logger -from fastcs.methods import CommandCallback +from fastcs.methods import Command +from fastcs.util import snake_to_pascal from .options import RestServerOptions from .util import ( @@ -56,8 +57,8 @@ def _put_request_body(attribute: AttrW[DType_T]): Creates a pydantic model for each datatype which defines the schema of the PUT request body """ - converted_datatype = convert_datatype(attribute.datatype) - type_name = str(attribute.datatype.dtype.__name__).title() + converted_datatype = convert_datatype(attribute.dtype) + type_name = str(attribute.dtype.__name__).title() # key=(type, ...) to declare a field without default value return create_model( f"Put{type_name}Value", @@ -69,7 +70,7 @@ def _wrap_attr_put( attribute: AttrW[DType_T], ) -> Callable[[DType_T], Coroutine[Any, Any, None]]: async def attr_put(request): - await attribute.put(cast_from_rest_type(attribute.datatype, request.value)) + await attribute.set(cast_from_rest_type(attribute, request.value)) # Fast api uses type annotations for validation, schema, conversions attr_put.__annotations__["request"] = _put_request_body(attribute) @@ -82,7 +83,7 @@ def _get_response_body(attribute: AttrR[DType_T]): Creates a pydantic model for each datatype which defines the schema of the GET request body """ - converted_datatype = convert_datatype(attribute.datatype) + converted_datatype = convert_datatype(attribute.dtype) type_name = str(converted_datatype.__name__).title() # key=(type, ...) to declare a field without default value return create_model( @@ -95,8 +96,8 @@ def _wrap_attr_get( attribute: AttrR[DType_T], ) -> Callable[[], Coroutine[Any, Any, dict[str, object]]]: async def attr_get() -> dict[str, object]: - value = attribute.get() - return {"value": cast_to_rest_type(attribute.datatype, value)} + value = attribute.readback + return {"value": cast_to_rest_type(attribute, value)} return attr_get @@ -142,13 +143,52 @@ def _add_attribute_api_routes(app: FastAPI, root_controller_api: ControllerAPI) ) +def _command_arguments_body(name: str, command: Command) -> type[BaseModel]: + """A pydantic model of a command's positional arguments, as a request body.""" + parameters = list(command.signature.parameters.values()) + # key=(type, ...) to declare a field without default value + fields: dict[str, Any] = { + parameter.name: (argument_type, ...) + for parameter, argument_type in zip( + parameters, command.argument_types, strict=True + ) + } + return create_model(f"Call{snake_to_pascal(name)}Arguments", **fields) + + +def _command_response_body(name: str, return_datatype: type) -> type[BaseModel]: + fields: dict[str, Any] = {"value": (return_datatype, ...)} + return create_model(f"Call{snake_to_pascal(name)}Result", **fields) + + def _wrap_command( - method: CommandCallback, -) -> Callable[..., Coroutine[None, None, None]]: - async def command() -> None: - await method() + name: str, command: Command +) -> Callable[..., Coroutine[None, None, dict[str, object] | None]]: + """Wrap a command in a route handler that carries its arguments and result.""" + argument_names = [ + parameter.name for parameter in command.signature.parameters.values() + ] + returns_a_value = command.return_datatype is not None + + if not argument_names: + + async def call() -> dict[str, object] | None: + result = await command.fn() + return {"value": result} if returns_a_value else None + + return call + + async def call_with_arguments(request) -> dict[str, object] | None: + arguments = [getattr(request, argument) for argument in argument_names] + result = await command.fn(*arguments) + return {"value": result} if returns_a_value else None + + # Fast api uses type annotations for validation, schema, conversions + call_with_arguments.__annotations__["request"] = _command_arguments_body( + name, command + ) - return command + return call_with_arguments def _add_command_api_routes(app: FastAPI, root_controller_api: ControllerAPI) -> None: @@ -157,10 +197,18 @@ def _add_command_api_routes(app: FastAPI, root_controller_api: ControllerAPI) -> for name, method in controller_api.command_methods.items(): cmd_name = name.replace("_", "-") - route = f"/{'/'.join(path)}/{cmd_name}" if path else cmd_name + route = f"{'/'.join(path)}/{cmd_name}" if path else cmd_name + return_datatype = method.return_datatype app.add_api_route( f"/{route}", - _wrap_command(method.fn), + _wrap_command(name, method), methods=["PUT"], - status_code=204, + # A command that gives something back has a body to return, so + # it answers 200 rather than 204 No Content. + status_code=200 if return_datatype is not None else 204, + response_model=( + _command_response_body(name, return_datatype) + if return_datatype is not None + else None + ), ) diff --git a/src/fastcs/transports/rest/util.py b/src/fastcs/transports/rest/util.py index c869a0d9f..2e8406ca8 100644 --- a/src/fastcs/transports/rest/util.py +++ b/src/fastcs/transports/rest/util.py @@ -2,9 +2,8 @@ import numpy as np -from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform - -REST_ALLOWED_DATATYPES = (Bool, DataType, Enum, Float, Int, String) +from fastcs.attributes import Attribute +from fastcs.datatypes import DType, DType_T, array_dtype_of _REST_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") @@ -20,32 +19,25 @@ def validate_rest_id(id: str) -> None: ) -def convert_datatype(datatype: DataType[DType_T]) -> type[DType_T]: +def convert_datatype(dtype: type[DType]) -> type: """Converts a datatype to a rest serialisable type.""" - match datatype: - case Waveform(): - return list - case _: - return datatype.dtype + if issubclass(dtype, np.ndarray): + return list + + return dtype -def cast_to_rest_type(datatype: DataType[DType_T], value: DType_T) -> object: +def cast_to_rest_type(attribute: Attribute[DType_T], value: DType_T) -> object: """Casts from an attribute value to a rest value.""" - match datatype: - case Waveform(): - return value.tolist() - case datatype if issubclass(type(datatype), REST_ALLOWED_DATATYPES): - return datatype.validate(value) - case _: - raise ValueError(f"Unsupported datatype {datatype}") + if issubclass(attribute.dtype, np.ndarray): + return value.tolist() # pyright: ignore[reportAttributeAccessIssue] + return attribute.validate(value) -def cast_from_rest_type(datatype: DataType[DType_T], value: object) -> DType_T: + +def cast_from_rest_type(attribute: Attribute[DType_T], value: object) -> DType_T: """Casts from a rest value to an attribute datatype.""" - match datatype: - case Waveform(): - return datatype.validate(np.array(value, dtype=datatype.array_dtype)) - case datatype if issubclass(type(datatype), REST_ALLOWED_DATATYPES): - return datatype.validate(value) # type: ignore - case _: - raise ValueError(f"Unsupported datatype {datatype}") + if issubclass(attribute.dtype, np.ndarray): + return attribute.validate(np.array(value, dtype=array_dtype_of(attribute.meta))) + + return attribute.validate(value) diff --git a/src/fastcs/transports/tango/dsr.py b/src/fastcs/transports/tango/dsr.py index 93aaab1c8..688d9a798 100644 --- a/src/fastcs/transports/tango/dsr.py +++ b/src/fastcs/transports/tango/dsr.py @@ -8,7 +8,8 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.methods import CommandCallback +from fastcs.logging import logger +from fastcs.methods import Command from .options import TangoDSROptions from .util import ( @@ -30,7 +31,7 @@ def _wrap_updater_fget( ) -> Callable[[Any], Any]: async def fget(tango_device: Device): tango_device.info_stream(f"called fget method: {attr_name}") - return cast_to_tango_type(attribute.datatype, attribute.get()) + return cast_to_tango_type(attribute, attribute.readback) return fget @@ -54,7 +55,7 @@ def _wrap_updater_fset( ) -> Callable[[Any, Any], Any]: async def fset(tango_device: Device, value): tango_device.info_stream(f"called fset method: {attr_name}") - coro = attribute.put(cast_from_tango_type(attribute.datatype, value)) + coro = attribute.set(cast_from_tango_type(attribute, value)) await _run_threadsafe_blocking(coro, loop) return fset @@ -84,7 +85,7 @@ def _collect_dev_attributes( ), access=AttrWriteType.READ_WRITE, **get_server_metadata_from_attribute(attribute), - **get_server_metadata_from_datatype(attribute.datatype), + **get_server_metadata_from_datatype(attribute), ) case AttrR(): collection[d_attr_name] = server.attribute( @@ -92,7 +93,7 @@ def _collect_dev_attributes( access=AttrWriteType.READ, fget=_wrap_updater_fget(attr_name, attribute, controller_api), **get_server_metadata_from_attribute(attribute), - **get_server_metadata_from_datatype(attribute.datatype), + **get_server_metadata_from_datatype(attribute), ) case AttrW(): collection[d_attr_name] = server.attribute( @@ -102,25 +103,56 @@ def _collect_dev_attributes( attr_name, attribute, controller_api, loop ), **get_server_metadata_from_attribute(attribute), - **get_server_metadata_from_datatype(attribute.datatype), + **get_server_metadata_from_datatype(attribute), ) return collection +# Tango commands carry at most one input value, so a command taking more than +# one argument has no faithful representation and is skipped (ADR 0015). +TANGO_MAX_COMMAND_ARGUMENTS = 1 + +TANGO_COMMAND_DTYPES: tuple[type, ...] = (bool, int, float, str) +"""The command argument and return types Tango can carry. + +An enum is left out: Tango has no command-level enum, and picking name-or-index +for it would be a guess a driver author cannot see or override. +""" + + +def _unservable_reason(command: Command) -> str | None: + """Why Tango cannot serve this command, or ``None`` if it can.""" + if len(command.argument_types) > TANGO_MAX_COMMAND_ARGUMENTS: + return "a Tango command takes at most one argument" + + unsupported = [ + datatype + for datatype in (*command.argument_types, command.return_datatype) + if datatype is not None and datatype not in TANGO_COMMAND_DTYPES + ] + if unsupported: + names = ", ".join(datatype.__name__ for datatype in unsupported) + return f"Tango commands do not carry {names}" + + return None + + def _wrap_command_f( method_name: str, - method: CommandCallback, + command: Command, controller_api: ControllerAPI, loop: asyncio.AbstractEventLoop, -) -> Callable[..., Awaitable[None]]: - async def _dynamic_f(tango_device: Device) -> None: +) -> Callable[..., Awaitable[Any]]: + takes_argument = bool(command.argument_types) + + async def _dynamic_f(tango_device: Device, *args) -> Any: tango_device.info_stream( f"called {'_'.join(controller_api.path)} f method: {method_name}" ) - coro = method() - await _run_threadsafe_blocking(coro, loop) + coro = command.fn(*args) if takes_argument else command.fn() + return await _run_threadsafe_blocking(coro, loop) _dynamic_f.__name__ = method_name return _dynamic_f @@ -136,10 +168,22 @@ def _collect_dev_commands( path = controller_api.path[root_depth:] for name, method in controller_api.command_methods.items(): + if (reason := _unservable_reason(method)) is not None: + logger.warning( + "Tango transport cannot serve this command", + command=name, + signature=str(method.signature), + reason=reason, + ) + method.enabled = False + continue + cmd_name = name.title().replace("_", "") d_cmd_name = f"{'_'.join(path)}_{cmd_name}" if path else cmd_name collection[d_cmd_name] = server.command( - f=_wrap_command_f(d_cmd_name, method.fn, controller_api, loop) + f=_wrap_command_f(d_cmd_name, method, controller_api, loop), + dtype_in=method.argument_types[0] if method.argument_types else None, + dtype_out=method.return_datatype, ) return collection diff --git a/src/fastcs/transports/tango/util.py b/src/fastcs/transports/tango/util.py index 9a82f264c..f1bd24348 100644 --- a/src/fastcs/transports/tango/util.py +++ b/src/fastcs/transports/tango/util.py @@ -1,24 +1,20 @@ +import enum import re -from dataclasses import asdict -from typing import Any +from typing import Any, cast +import numpy as np from tango import AttrDataFormat from fastcs.attributes import Attribute from fastcs.datatypes import ( - Bool, - DataType, + DEFAULT_ARRAY_SHAPE, + DEFAULT_PRECISION, DType, DType_T, - Enum, - Float, - Int, - String, - Waveform, + NumericLimits, + array_dtype_of, ) -TANGO_ALLOWED_DATATYPES = (Bool, DataType, Enum, Float, Int, String, Waveform) - _TANGO_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") @@ -55,15 +51,6 @@ def tango_dev_name(id: str, dsr_instance: str) -> str: return f"{id}/{tango_dev_class_name(id)}/{dsr_instance}" -DATATYPE_FIELD_TO_SERVER_FIELD = { - "units": "unit", - "min": "min_value", - "max": "max_value", - "min_alarm": "min_alarm", - "max_alarm": "min_alarm", -} - - def get_server_metadata_from_attribute( attribute: Attribute[DType], ) -> dict[str, Any]: @@ -73,33 +60,45 @@ def get_server_metadata_from_attribute( return arguments -def get_server_metadata_from_datatype(datatype: DataType[DType]) -> dict[str, str]: - """Gets the metadata for a Tango field from a FastCS datatype.""" - arguments = { - DATATYPE_FIELD_TO_SERVER_FIELD[field]: value - for field, value in asdict(datatype).items() - if field in DATATYPE_FIELD_TO_SERVER_FIELD +def _limit_arguments(limits: NumericLimits | None) -> dict[str, Any]: + if limits is None: + return {} + + return { + "min_value": limits.control.low, + "max_value": limits.control.high, + "min_alarm": limits.alarm.low, + "max_alarm": limits.alarm.high, + "min_warning": limits.warning.low, + "max_warning": limits.warning.high, } - dtype = datatype.dtype - - match datatype: - case Waveform(): - dtype = datatype.array_dtype - match len(datatype.shape): - case 1: - arguments["max_dim_x"] = datatype.shape[0] - arguments["dformat"] = AttrDataFormat.SPECTRUM - case 2: - arguments["max_dim_x"], arguments["max_dim_y"] = datatype.shape - arguments["dformat"] = AttrDataFormat.IMAGE - case _: - raise TypeError( - f"Unsupported shape {datatype.shape}, Tango supports up " - "to 2D arrays" - ) - case Float(): - arguments["format"] = f"%.{datatype.prec}" + +def get_server_metadata_from_datatype(attribute: Attribute[DType]) -> dict[str, Any]: + """Gets the metadata for a Tango field from an attribute's datatype.""" + meta = attribute.meta + dtype: Any = attribute.dtype + + arguments: dict[str, Any] = {"unit": meta.get("units")} + arguments.update(_limit_arguments(meta.get("limits"))) + + if issubclass(attribute.dtype, np.ndarray): + dtype = array_dtype_of(meta) + shape = meta.get("shape", DEFAULT_ARRAY_SHAPE) + match len(shape): + case 1: + arguments["max_dim_x"] = shape[0] + arguments["dformat"] = AttrDataFormat.SPECTRUM + case 2: + arguments["max_dim_x"] = shape[0] + arguments["max_dim_y"] = shape[1] + arguments["dformat"] = AttrDataFormat.IMAGE + case _: + raise TypeError( + f"Unsupported shape {shape}, Tango supports up to 2D arrays" + ) + elif attribute.dtype is float: + arguments["format"] = f"%.{meta.get('precision', DEFAULT_PRECISION)}" arguments["dtype"] = dtype for argument, value in arguments.items(): @@ -109,24 +108,19 @@ def get_server_metadata_from_datatype(datatype: DataType[DType]) -> dict[str, st return arguments -def cast_to_tango_type(datatype: DataType[DType_T], value: DType_T) -> object: +def cast_to_tango_type(attribute: Attribute[DType_T], value: DType_T) -> object: """Casts a value from FastCS to tango datatype.""" - match datatype: - case Enum(): - return datatype.index_of(datatype.validate(value)) - case datatype if issubclass(type(datatype), TANGO_ALLOWED_DATATYPES): - return datatype.validate(value) - case _: - raise ValueError(f"Unsupported datatype {datatype}") + if issubclass(attribute.dtype, enum.Enum): + member = cast(enum.Enum, attribute.validate(value)) + return list(attribute.dtype).index(member) + + return attribute.validate(value) -def cast_from_tango_type(datatype: DataType[DType_T], value: object) -> DType_T: +def cast_from_tango_type(attribute: Attribute[DType_T], value: object) -> DType_T: """Casts a value from tango to FastCS datatype.""" - match datatype: - case Enum(): - assert isinstance(value, int), "Got non-integer value for Enum" - return datatype.validate(datatype.members[value]) - case datatype if issubclass(type(datatype), TANGO_ALLOWED_DATATYPES): - return datatype.validate(value) # type: ignore - case _: - raise ValueError(f"Unsupported datatype {datatype}") + if issubclass(attribute.dtype, enum.Enum): + assert isinstance(value, int), "Got non-integer value for Enum" + return attribute.validate(list(attribute.dtype)[value]) + + return attribute.validate(value) diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index c57916134..8299bff91 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -1,44 +1,23 @@ import copy from contextlib import contextmanager -from dataclasses import dataclass from typing import Literal from pytest_mock import MockerFixture, MockType -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR from fastcs.controllers import Controller, ControllerAPI -from fastcs.datatypes import DType_T, Int from fastcs.methods import command, scan -@dataclass -class MyTestAttributeIORef(AttributeIORef): - update_period = 1 - - -class MyTestAttributeIO(AttributeIO[DType_T, MyTestAttributeIORef]): - async def update(self, attr: AttrR[DType_T, MyTestAttributeIORef]): - print(f"update {attr}") - - async def send(self, attr: AttrW[DType_T, MyTestAttributeIORef], value: DType_T): - print(f"sending {attr} = {value}") - if isinstance(attr, AttrRW): - await attr.update(value) - - -test_attribute_io = MyTestAttributeIO() # instance - - class TestSubController(Controller): - read_int: AttrR = AttrR(Int(), io_ref=MyTestAttributeIORef()) - def __init__(self) -> None: - super().__init__(ios=[test_attribute_io]) + super().__init__() + self.read_int = AttrR(int) class MyTestController(Controller): def __init__(self) -> None: - super().__init__(ios=[test_attribute_io]) + super().__init__() self._sub_controllers: list[TestSubController] = [] for index in range(1, 3): @@ -97,29 +76,67 @@ def __init__( @contextmanager def assert_read_here(self, path: list[str]): - yield from self._assert_method(path, "get") + yield from self._assert_readback(path) @contextmanager def assert_write_here(self, path: list[str]): - yield from self._assert_method(path, "put") + yield from self._assert_method(path, "set") @contextmanager def assert_execute_here(self, path: list[str]): yield from self._assert_method(path, "") - def _assert_method(self, path: list[str], method: Literal["get", "put", ""]): + def _navigate(self, path: list[str]) -> tuple[ControllerAPI, str]: + queue = copy.deepcopy(path) + controller_api: ControllerAPI = self + item_name = queue.pop(-1) + for item in queue: + controller_api = controller_api.sub_apis[item] + return controller_api, item_name + + def _assert_readback(self, path: list[str]): + """Confirm that an attribute's ``readback`` property is read exactly once + within a context block. + + ``readback`` is a read-only property, so it can't be spied on with + ``mocker.spy`` (which needs to reassign the instance attribute). Instead, + temporarily replace the property on the attribute's class with a counting + wrapper, scoped to just this one instance. + """ + controller_api, item_name = self._navigate(path) + attr = controller_api.attributes[item_name] + assert isinstance(attr, AttrR) + cls = type(attr) + original = cls.readback + assert original.fget is not None + original_fget = original.fget + call_count = {"n": 0} + + def fget(self): + if self is attr: + call_count["n"] += 1 + return original_fget(self) + + cls.readback = property(fget) # type: ignore[misc] + try: + yield # Enter context + except Exception as e: + raise e + else: # Exit context + assert call_count["n"] == 1, ( + f"Expected {'.'.join(path + ['readback'])} to be read once, " + f"but it was read {call_count['n']} times." + ) + finally: + cls.readback = original # type: ignore[misc] + + def _assert_method(self, path: list[str], method: Literal["set", ""]): """ This context manager can be used to confirm that a fastcs controller's respective attribute or command methods are called a single time within a context block """ - queue = copy.deepcopy(path) - - # Navigate to sub controller - controller_api = self - item_name = queue.pop(-1) - for item in queue: - controller_api = controller_api.sub_apis[item] + controller_api, item_name = self._navigate(path) # Get spy if method: diff --git a/tests/benchmarking/controller.py b/tests/benchmarking/controller.py index fc2d187e9..19932655b 100644 --- a/tests/benchmarking/controller.py +++ b/tests/benchmarking/controller.py @@ -3,7 +3,6 @@ from fastcs import FastCS from fastcs.attributes import AttrR, AttrW from fastcs.controllers import Controller -from fastcs.datatypes import Bool, Int from fastcs.transports.epics.ca.transport import EpicsCATransport from fastcs.transports.rest.options import RestServerOptions from fastcs.transports.rest.transport import RestTransport @@ -11,8 +10,8 @@ class MyTestController(Controller): - read_int: AttrR = AttrR(Int(), initial_value=0) - write_bool: AttrW = AttrW(Bool()) + read_int: AttrR = AttrR(int, initial_value=0) + write_bool: AttrW = AttrW(bool) def run(): diff --git a/tests/conftest.py b/tests/conftest.py index 818c7d178..937526e78 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,12 +19,11 @@ from softioc import builder from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.datatypes import Bool, Float, Int, String from fastcs.logging import configure_logging, logger from fastcs.logging._logging import LogLevel from fastcs.transports.tango.dsr import FASTCS_TANGO_SERVER_NAME, register_dev from fastcs.transports.tango.util import tango_dev_class_name, tango_dev_name -from tests.assertable_controller import MyTestAttributeIORef, MyTestController +from tests.assertable_controller import MyTestController from tests.example_p4p_ioc import run as _run_p4p_ioc from tests.example_softioc import run as _run_softioc @@ -42,12 +41,12 @@ def clear_softioc_records(): class BackendTestController(MyTestController): - read_int: AttrR = AttrR(Int(), io_ref=MyTestAttributeIORef()) - read_write_int: AttrRW = AttrRW(Int(), io_ref=MyTestAttributeIORef()) - read_write_float: AttrRW = AttrRW(Float()) - read_bool: AttrR = AttrR(Bool()) - write_bool: AttrW = AttrW(Bool(), io_ref=MyTestAttributeIORef()) - read_string: AttrRW = AttrRW(String()) + read_int: AttrR = AttrR(int) + read_write_int: AttrRW = AttrRW(int) + read_write_float: AttrRW = AttrRW(float) + read_bool: AttrR = AttrR(bool) + write_bool: AttrW = AttrW(bool) + read_string: AttrRW = AttrRW(str) @pytest.fixture diff --git a/tests/demo/test_eiger.py b/tests/demo/test_eiger.py new file mode 100644 index 000000000..2b518c8bb --- /dev/null +++ b/tests/demo/test_eiger.py @@ -0,0 +1,144 @@ +import asyncio +import enum + +import httpx +import pytest +import pytest_asyncio + +from fastcs.attributes import AttrR, AttrRW +from fastcs.demo.eiger import UPDATE_PERIOD, EigerDetector +from fastcs.demo.simulation.eiger import EigerParameter, create_eiger_sim_app +from fastcs.util import ONCE + +# Backdoor to the sim's parameter tree, keyed by subsystem then param name. +SimState = dict[str, dict[str, EigerParameter]] + + +@pytest_asyncio.fixture +async def _eiger(): + app = create_eiger_sim_app() + controller = EigerDetector(transport=httpx.ASGITransport(app=app)) + await controller.connect() + await controller.initialise() + controller.post_initialise() + yield controller, app.state.sim + await controller.disconnect() + + +@pytest_asyncio.fixture +async def detector(_eiger) -> EigerDetector: + return _eiger[0] + + +@pytest_asyncio.fixture +async def sim(_eiger) -> SimState: + return _eiger[1] + + +@pytest.mark.asyncio +async def test_hinted_attributes_are_introspected(detector: EigerDetector): + assert isinstance(detector.count_time, AttrRW) + assert detector.count_time.dtype is float + + assert isinstance(detector.state, AttrR) + # ``state`` reports ``allowed_values``, so it is introspected as an enum whose + # members come from the device rather than as a bare string. + assert issubclass(detector.state.dtype, enum.Enum) + assert [member.name for member in detector.state.dtype] == [ + "idle", + "ready", + "acquire", + ] + + +@pytest.mark.asyncio +async def test_enum_attribute_reads_as_member(detector: EigerDetector, sim: SimState): + sim["status"]["state"].value = "acquire" + await detector.state.poll() + + state = detector.state.readback + assert isinstance(state, enum.Enum) + assert state.value == "acquire" + + +@pytest.mark.asyncio +async def test_unhinted_attributes_are_also_introspected(detector: EigerDetector): + for name in ("frame_time", "nimages", "description", "temperature", "humidity"): + assert name in detector.attributes + + +@pytest.mark.asyncio +async def test_read_attribute_from_device(detector: EigerDetector): + await detector.count_time.poll() + assert detector.count_time.readback == 0.1 + + humidity = detector.attributes["humidity"] + assert isinstance(humidity, AttrR) + await humidity.poll() + assert humidity.readback == 32.1 + + +@pytest.mark.asyncio +async def test_write_attribute_to_device(detector: EigerDetector): + await detector.count_time.set(0.5) + + # Read it back through the attribute to confirm the round-trip to the device. + await detector.count_time.poll() + assert detector.count_time.readback == 0.5 + + +@pytest.mark.asyncio +async def test_idle_derived_from_state(detector: EigerDetector, sim: SimState): + # ``idle`` is soft and starts at its default, tracking ``state`` once polled. + assert detector.idle.readback is False + + # Poke the read-only ``state`` via the sim backdoor, then poll the attribute. + sim["status"]["state"].value = "acquire" + await detector.state.poll() + assert detector.idle.readback is False + + sim["status"]["state"].value = "idle" + await detector.state.poll() + assert detector.idle.readback is True + + +@pytest.mark.asyncio +async def test_read_only_params_poll_but_rw_read_once(detector: EigerDetector): + for name in ("state", "temperature", "humidity", "description"): + attr = detector.attributes[name] + assert isinstance(attr, AttrR) and not isinstance(attr, AttrRW) + assert attr.poll_period == UPDATE_PERIOD + + assert detector.count_time.poll_period is ONCE + + +@pytest.mark.asyncio +async def test_temperature_oscillation_seen_via_subscribe(): + # The oscillation task runs under the app lifespan, so drive the lifespan here + # (the bare ASGI transport used elsewhere does not start it). Observe it through + # the controller's temperature attribute, subscribing for updates. + app = create_eiger_sim_app() + async with app.router.lifespan_context(app): + controller = EigerDetector(transport=httpx.ASGITransport(app=app)) + await controller.connect() + await controller.initialise() + controller.post_initialise() + + temperature = controller.attributes["temperature"] + assert isinstance(temperature, AttrR) + + seen: list[float] = [] + + async def record(value: float) -> None: + seen.append(value) + + temperature.add_readback_callback(record) + + # Poll across several sim flips (every 0.5s) so the value changes under us. + for _ in range(8): + await temperature.poll() + await asyncio.sleep(0.2) + + await controller.disconnect() + + assert len(set(seen)) > 1, f"temperature did not change: {seen}" diff --git a/tests/demo/test_temperature_attr.py b/tests/demo/test_temperature_attr.py new file mode 100644 index 000000000..bab260065 --- /dev/null +++ b/tests/demo/test_temperature_attr.py @@ -0,0 +1,147 @@ +from unittest.mock import AsyncMock + +import numpy as np +import pytest + +from fastcs.attributes import AttrW +from fastcs.connections import IPConnectionSettings +from fastcs.controllers import ControllerVector +from fastcs.demo.temperature_attr import ( + OnOffEnum, + TemperatureController, + TemperatureControllerSettings, + TemperatureRampController, +) + + +@pytest.fixture +def controller() -> TemperatureController: + settings = TemperatureControllerSettings( + num_ramp_controllers=4, + ip_settings=IPConnectionSettings(ip="localhost", port=25565), + ) + controller = TemperatureController(settings) + controller.post_initialise() + return controller + + +@pytest.fixture +def ramp_controller(controller: TemperatureController) -> TemperatureRampController: + return controller.ramps[1] + + +def test_ramps_is_controller_vector(controller: TemperatureController): + assert isinstance(controller.ramps, ControllerVector) + assert list(controller.ramps) == [1, 2, 3, 4] + for index, ramp in controller.ramps.items(): + assert isinstance(ramp, TemperatureRampController) + assert controller.ramps[index] is ramp + + +@pytest.mark.asyncio +async def test_ramp_rate_read_from_device(controller: TemperatureController): + controller.connection.send_query = AsyncMock(return_value="1.5\r\n") + + await controller.ramp_rate.poll() + + controller.connection.send_query.assert_awaited_once_with("R?\r\n") + assert controller.ramp_rate.readback == 1.5 + + +@pytest.mark.asyncio +async def test_ramp_rate_written_to_device(controller: TemperatureController): + controller.connection.send_command = AsyncMock() + + await controller.ramp_rate.set(2.5) + + controller.connection.send_command.assert_awaited_once_with("R=2.5\r\n") + + +@pytest.mark.asyncio +async def test_power_read_from_device(controller: TemperatureController): + controller.connection.send_query = AsyncMock(return_value="10.25\r\n") + + await controller.power.poll() + + controller.connection.send_query.assert_awaited_once_with("P?\r\n") + assert controller.power.readback == 10.25 + + +@pytest.mark.asyncio +async def test_ramp_start_read_from_device(ramp_controller: TemperatureRampController): + ramp_controller.connection.send_query = AsyncMock(return_value="7\r\n") + + await ramp_controller.start.poll() + + ramp_controller.connection.send_query.assert_awaited_once_with("S01?\r\n") + assert ramp_controller.start.readback == 7 + + +@pytest.mark.asyncio +async def test_ramp_end_written_to_device(ramp_controller: TemperatureRampController): + ramp_controller.connection.send_command = AsyncMock() + + await ramp_controller.end.set(42) + + ramp_controller.connection.send_command.assert_awaited_once_with("E01=42\r\n") + + +@pytest.mark.asyncio +async def test_ramp_enabled_written_to_device( + ramp_controller: TemperatureRampController, +): + ramp_controller.connection.send_command = AsyncMock() + + await ramp_controller.enabled.set(OnOffEnum.On) + + ramp_controller.connection.send_command.assert_awaited_once_with("N01=1\r\n") + + +@pytest.mark.asyncio +async def test_each_ramp_addresses_its_own_index(controller: TemperatureController): + controller.connection.send_command = AsyncMock() + + for index, ramp in controller.ramps.items(): + await ramp.start.set(index) + + assert [ + call.args[0] for call in controller.connection.send_command.await_args_list + ] == ["S01=1\r\n", "S02=2\r\n", "S03=3\r\n", "S04=4\r\n"] + + +@pytest.mark.asyncio +async def test_read_only_attribute_has_no_setter( + ramp_controller: TemperatureRampController, +): + # Access mode is structural now: no setter means it is not an AttrW at all. + assert not isinstance(ramp_controller.target, AttrW) + assert ramp_controller.start.has_setter() + + +@pytest.mark.asyncio +async def test_cancel_all_disables_every_ramp(controller: TemperatureController): + sets = {} + for index, ramp in controller.ramps.items(): + sets[index] = AsyncMock() + ramp.enabled.set = sets[index] # type: ignore[method-assign] + + await controller.cancel_all() + + for set_ in sets.values(): + set_.assert_awaited_once_with(OnOffEnum.Off) + + +@pytest.mark.asyncio +async def test_update_voltages_updates_waveform_and_each_ramp( + controller: TemperatureController, +): + controller.connection.send_query = AsyncMock(return_value="[1, 2, 3, 4]\r\n") + + await controller.update_voltages() + + controller.connection.send_query.assert_awaited_once_with("V?\r\n") + np.testing.assert_array_equal( + controller.voltages.readback, np.array([1, 2, 3, 4], dtype=np.int32) + ) + for index, ramp in controller.ramps.items(): + assert ramp.voltage.readback == pytest.approx(float(index)) diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index 95cc8e70b..f41b74d6c 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -1,28 +1,18 @@ import asyncio import enum -from dataclasses import dataclass +from pathlib import Path import numpy as np -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Bool, DType_T, Enum, Float, Int, Table, Waveform +from fastcs.datatypes import Array1D, Limits, NumericLimits, Table from fastcs.launch import FastCS from fastcs.methods import command, scan +from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.pva import EpicsPVATransport -@dataclass -class SimpleAttributeIORef(AttributeIORef): - pass - - -class SimpleAttributeIO(AttributeIO[DType_T, SimpleAttributeIORef]): - async def send(self, attr: AttrW[DType_T, SimpleAttributeIORef], value): - if isinstance(attr, AttrRW): - await attr.update(value) - - class FEnum(enum.Enum): A = 0 B = 1 @@ -34,42 +24,56 @@ class FEnum(enum.Enum): class ParentController(Controller): description = "some controller" a: AttrRW = AttrRW( - Int(max=400_000, max_alarm=40_000), io_ref=SimpleAttributeIORef() + int, + limits=NumericLimits(control=Limits(high=400_000), alarm=Limits(high=40_000)), + ) + b: AttrW = AttrW( + float, limits=NumericLimits(control=Limits(low=-1), alarm=Limits(low=-0.5)) ) - b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5), io_ref=SimpleAttributeIORef()) table: AttrRW = AttrRW( - Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]), - io_ref=SimpleAttributeIORef(), + Table, + structured_dtype=[("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)], ) - def __init__(self, description=None, ios=None): - super().__init__(description, ios) - class ChildController(Controller): fail_on_next_e = True - c: AttrW = AttrW(Int(), io_ref=SimpleAttributeIORef()) + c: AttrW = AttrW(int) + + def __init__(self, description: str | None = None): + super().__init__(description=description) + + # A getter/setter pair against an in-memory "device", doing what an + # AttributeIO used to do. The setter clamps the requested value and + # returns what it accepted, which becomes both the readback and the + # setpoint; the getter seeds the setpoint when the controller connects. + self._clamped = 5 + self.clamped = AttrRW(int, getter=self.get_clamped, setter=self.set_clamped) + + async def get_clamped(self) -> int: + return self._clamped - def __init__(self, description=None, ios=None): - super().__init__(description, ios) + async def set_clamped(self, value: int) -> int: + self._clamped = min(max(value, 0), 100) + return self._clamped @command() async def d(self): print("D: RUNNING") await asyncio.sleep(0.1) print("D: FINISHED") - await self.j.update(self.j.get() + 1) + await self.j.update(self.j.readback + 1) - e: AttrR = AttrR(Bool(), io_ref=SimpleAttributeIORef()) + e: AttrR = AttrR(bool) @scan(1) async def flip_flop(self): - await self.e.update(not self.e.get()) + await self.e.update(not self.e.readback) - f: AttrRW = AttrRW(Enum(FEnum)) - g: AttrRW = AttrRW(Waveform(np.int64, shape=(3,))) - h: AttrRW = AttrRW(Waveform(np.float64, shape=(3, 3))) + f: AttrRW = AttrRW(FEnum) + g: AttrRW = AttrRW(Array1D[np.int64], shape=(3,)) + h: AttrRW = AttrRW(Array1D[np.float64], shape=(3, 3)) @command() async def i(self): @@ -81,31 +85,27 @@ async def i(self): else: self.fail_on_next_e = True print("I: FINISHED") - await self.j.update(self.j.get() + 1) + await self.j.update(self.j.readback + 1) - j: AttrR = AttrR(Int()) + j: AttrR = AttrR(int) def run(id="P4P_TEST_DEVICE"): - simple_attribute_io = SimpleAttributeIO() - p4p_options = EpicsPVATransport() - controller = ParentController(ios=[simple_attribute_io]) + gui_options = EpicsGUIOptions(output_dir=Path("./opis"), title="Demo Vector") + p4p_options = EpicsPVATransport(gui=gui_options) + controller = ParentController() controller.set_path([id]) class ChildVector(ControllerVector): - vector_attribute: AttrR = AttrR(Int()) + vector_attribute: AttrR = AttrR(int) def __init__(self, children, description=None): super().__init__(children, description) sub_controller = ChildVector( { - 1: ChildController( - description="some sub controller", ios=[simple_attribute_io] - ), - 2: ChildController( - description="another sub controller", ios=[simple_attribute_io] - ), + 1: ChildController(description="some sub controller"), + 2: ChildController(description="another sub controller"), }, description="some child vector", ) diff --git a/tests/example_softioc.py b/tests/example_softioc.py index 14c1a0278..88da09e1e 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -3,7 +3,6 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.control_system import FastCS from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Int from fastcs.methods import command from fastcs.transports.epics.ca.transport import ( EpicsCAOptions, @@ -13,12 +12,24 @@ class ParentController(Controller): - a: AttrR = AttrR(Int()) - b: AttrRW = AttrRW(Int()) + a: AttrR = AttrR(int) + b: AttrRW = AttrRW(int) + + def __init__(self, description: str | None = None) -> None: + super().__init__(description) + self._clamped = 5 + self.clamped = AttrRW(int, getter=self.get_clamped, setter=self.set_clamped) + + async def get_clamped(self) -> int: + return self._clamped + + async def set_clamped(self, value: int) -> int: + self._clamped = min(max(value, 0), 100) + return self._clamped class ChildController(Controller): - c: AttrW = AttrW(Int()) + c: AttrW = AttrW(int) @command() async def d(self): @@ -30,7 +41,7 @@ def run(id="SOFTIOC_TEST_DEVICE"): controller.set_path([id]) vector = ControllerVector({i: ChildController() for i in range(2)}) controller.add_sub_controller("ChildVector", vector) - gui_options = EpicsGUIOptions(output_dir=Path("."), title="Demo Vector") + gui_options = EpicsGUIOptions(output_dir=Path("./opis"), title="Demo Vector") fastcs = FastCS( controller, [ diff --git a/tests/test_attr_decorator.py b/tests/test_attr_decorator.py new file mode 100644 index 000000000..711cca1c9 --- /dev/null +++ b/tests/test_attr_decorator.py @@ -0,0 +1,399 @@ +import asyncio +from enum import Enum + +import numpy as np +import pytest + +from fastcs.attributes import ( + AttrR, + AttrRW, + NotPolled, + Polled, + UnboundAttr, + Update, + attr, +) +from fastcs.controllers import Controller +from fastcs.datatypes import Array1D, Limits, NumericLimits +from fastcs.util import ONCE + + +class State(Enum): + IDLE = "idle" + BUSY = "busy" + + +class PowerSupply(Controller): + """A controller declaring its attributes with ``@attr``.""" + + def __init__(self) -> None: + super().__init__() + + self.sent: list[float] = [] + self._voltage = 1.5 + + @attr(Polled(period=0.5), units="V", precision=3) + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + """Output voltage. + + The rest of the docstring says more than a description should. + """ + return self._voltage + + @voltage.setter + async def voltage(self, value: float) -> None: + self.sent.append(value) + self._voltage = value + + @attr + async def serial(self) -> str: + """Serial number.""" + return "PSU-1" + + @attr(NotPolled(), group="Config") + async def retries(self) -> int: + return 3 + + +def test_getter_only_is_read_only(): + controller = PowerSupply() + + assert isinstance(controller.serial, AttrR) + assert not isinstance(controller.serial, AttrRW) + assert controller.serial.dtype is str + assert controller.serial.access_mode == "r" + + +def test_getter_and_setter_is_read_write(): + controller = PowerSupply() + + assert isinstance(controller.voltage, AttrRW) + assert controller.voltage.dtype is float + assert controller.voltage.access_mode == "rw" + + +def test_attributes_are_registered_with_the_controller(): + controller = PowerSupply() + + assert list(controller.attributes) == ["voltage", "serial", "retries"] + assert controller.attributes["voltage"] is controller.voltage + assert controller.voltage.name == "voltage" + + +def test_metadata_from_decorator(): + controller = PowerSupply() + + assert controller.voltage.meta == { + "units": "V", + "precision": 3, + "description": "Output voltage.", + } + assert controller.retries.meta == {"group": "Config"} + assert controller.retries.group == "Config" + + +def test_docstring_summary_becomes_the_description(): + controller = PowerSupply() + + # Only the first paragraph - a description is a one-line label. + assert controller.voltage.description == "Output voltage." + assert controller.serial.description == "Serial number." + assert controller.retries.description is None + + +def test_explicit_description_wins_over_the_docstring(): + class Device(Controller): + @attr(description="From the decorator") + async def label(self) -> str: + """From the docstring.""" + return "x" + + assert Device().label.description == "From the decorator" + + +def test_schedules(): + controller = PowerSupply() + + assert controller.voltage.poll_period == 0.5 + # A bare ``@attr`` means what a bare ``getter=`` means - read once, at connect. + assert controller.serial.poll_period is ONCE + assert controller.retries.poll_period is None + assert controller.retries.has_getter() + + +@pytest.mark.asyncio +async def test_bound_getter_reads_from_its_own_instance(): + one, two = PowerSupply(), PowerSupply() + two._voltage = 9.0 + + assert await one.voltage.poll() == 1.5 + assert await two.voltage.poll() == 9.0 + + +@pytest.mark.asyncio +async def test_bound_setter_writes_to_its_own_instance(): + one, two = PowerSupply(), PowerSupply() + + await one.voltage.set(2.5) + + assert one.sent == [2.5] + assert one._voltage == 2.5 + assert two.sent == [] + assert two._voltage == 1.5 + + +def test_each_instance_gets_a_fresh_attribute(): + one, two = PowerSupply(), PowerSupply() + + assert one.voltage is not two.voltage + assert one.serial is not two.serial + + +def test_class_body_holds_the_declaration(): + assert isinstance(PowerSupply.voltage, UnboundAttr) + assert PowerSupply.voltage.datatype is float + assert PowerSupply.voltage.has_setter() + assert not PowerSupply.serial.has_setter() + assert "PowerSupply.voltage" in repr(PowerSupply.voltage) + assert "access_mode='rw'" in repr(PowerSupply.voltage) + + +def test_datatype_inferred_from_the_return_annotation(): + class Device(Controller): + @attr + async def flag(self) -> bool: + return True + + @attr + async def state(self) -> State: + return State.IDLE + + @attr(shape=(4,)) + async def trace(self) -> Array1D[np.int32]: + return np.zeros(4, dtype=np.int32) + + controller = Device() + + assert controller.flag.dtype is bool + assert controller.state.dtype is State + assert controller.trace.dtype is np.ndarray + assert controller.trace.meta == {"array_dtype": np.int32, "shape": (4,)} + + +@pytest.mark.asyncio +async def test_update_return_annotation_is_unwrapped(): + class Device(Controller): + @attr + async def temperature(self) -> Update[float]: + return Update(readback=20.5, timestamp=1000.0) + + controller = Device() + + assert controller.temperature.dtype is float + assert await controller.temperature.poll() == 20.5 + assert controller.temperature.timestamp == 1000.0 + + +def test_metadata_is_validated_against_the_inferred_datatype(): + class Device(Controller): + @attr(precision=3) + async def label(self) -> str: + return "x" + + with pytest.raises(TypeError, match="'precision' is not valid metadata"): + Device() + + +def test_limits_metadata(): + class Device(Controller): + @attr(limits=NumericLimits(control=Limits(0.0, 10.0))) + async def setpoint(self) -> float: + return 1.0 + + assert Device().setpoint.meta.get("limits") == NumericLimits( + control=Limits(0.0, 10.0) + ) + + +def test_matching_type_hint_is_satisfied_by_the_decorated_attribute(): + class Device(Controller): + label: AttrR[str] # pyright: ignore[reportRedeclaration] + + @attr + async def label(self) -> str: + return "x" + + controller = Device() + + assert isinstance(controller.label, AttrR) + + +def test_type_hint_of_the_wrong_access_mode_raises(): + class Device(Controller): + label: AttrRW[str] # pyright: ignore[reportRedeclaration] + + @attr + async def label(self) -> str: + return "x" + + with pytest.raises(RuntimeError, match="does not match defined access mode"): + Device() + + +def test_name_clash_with_an_attribute_added_later_raises(): + class Device(Controller): + def __init__(self) -> None: + super().__init__() + + self.label = AttrR(str) # pyright: ignore[reportAttributeAccessIssue] + + @attr + async def label(self) -> str: + return "x" + + with pytest.raises(ValueError, match="Cannot add attribute") as exc_info: + Device() + + assert "has existing attribute label" in str(exc_info.value.__cause__) + + +def test_getter_must_be_async(): + with pytest.raises(TypeError, match="getter .* must be an async function"): + + @attr() # pyright: ignore[reportArgumentType] + def voltage(self) -> float: + return 0.0 + + +def test_getter_must_take_only_self(): + with pytest.raises(TypeError, match="getter .* must be a method taking self"): + + @attr() # pyright: ignore[reportArgumentType] + async def voltage(self, index: int) -> float: + return 0.0 + + +def test_getter_must_annotate_its_return_type(): + with pytest.raises(TypeError, match="must annotate the datatype"): + + @attr() + async def voltage(self): + return 0.0 + + +def test_getter_must_return_a_supported_datatype(): + with pytest.raises(TypeError, match="must annotate a supported datatype"): + + @attr() # pyright: ignore[reportArgumentType] + async def voltage(self) -> list[int]: + return [] + + +def test_setter_must_be_async(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + with pytest.raises(TypeError, match="setter .* must be an async function"): + + @voltage.setter # pyright: ignore[reportArgumentType] + def voltage(self, value: float) -> None: + pass + + +def test_setter_must_take_a_value(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + with pytest.raises( + TypeError, match="setter .* must be a method taking self and the value to set" + ): + + @voltage.setter # pyright: ignore[reportArgumentType] + async def voltage(self) -> None: + pass + + +def test_setter_value_must_match_the_getter_datatype(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + with pytest.raises(TypeError, match="takes a str, but its getter returns a float"): + + @voltage.setter # pyright: ignore[reportArgumentType] + async def voltage(self, value: str) -> None: + pass + + +def test_setter_value_annotation_is_optional(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + @voltage.setter + async def voltage(self, value) -> None: + pass + + assert voltage.has_setter() + + +def test_only_one_setter(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + @voltage.setter + async def voltage(self, value: float) -> None: + pass + + with pytest.raises(TypeError, match="already has a setter"): + + @voltage.setter + async def voltage(self, value: float) -> None: + pass + + +def test_setter_does_not_leak_onto_the_class_it_was_inherited_from(): + class Base(Controller): + @attr + async def voltage(self) -> float: + return 0.0 + + class Child(Base): + @Base.voltage.setter # pyright: ignore[reportArgumentType] + async def voltage(self, value: float) -> None: + pass + + assert not Base.voltage.has_setter() + assert Child.voltage.has_setter() + assert not isinstance(Base().voltage, AttrRW) + assert isinstance(Child().voltage, AttrRW) + + +def test_schedule_must_not_already_have_a_getter(): + async def read() -> float: + return 0.0 + + with pytest.raises(TypeError, match="already has a getter"): + + @attr(Polled(read, period=0.1)) + async def voltage(self) -> float: + return 0.0 + + +@pytest.mark.asyncio +async def test_polled_attributes_are_scheduled(): + controller = PowerSupply() + _, periodic, initial = controller.create_api_and_tasks() + + # ``serial`` is read once at connect; ``voltage`` is polled at 0.5s; + # ``retries`` is never read on a schedule. + assert len(initial) == 1 + assert len(periodic) == 1 + + await asyncio.gather(*[coro() for coro in initial]) + + assert controller.serial.readback == "PSU-1" + assert controller.retries.readback == 0 diff --git a/tests/test_attribute_logging.py b/tests/test_attribute_logging.py index e24a47db4..851c1056b 100644 --- a/tests/test_attribute_logging.py +++ b/tests/test_attribute_logging.py @@ -1,13 +1,12 @@ import pytest from fastcs.attributes import AttrR -from fastcs.datatypes import Int @pytest.mark.asyncio async def test_attr_r_update_trace_logs_when_tracing_enabled(loguru_caplog): """log_event emits 'Attribute set' and 'Value validated' when tracing is on.""" - attr = AttrR(Int()) + attr = AttrR(int) attr.enable_tracing() await attr.update(42) @@ -19,7 +18,7 @@ async def test_attr_r_update_trace_logs_when_tracing_enabled(loguru_caplog): @pytest.mark.asyncio async def test_attr_r_update_no_trace_logs_when_tracing_disabled(loguru_caplog): - attr = AttrR(Int()) + attr = AttrR(int) await attr.update(42) @@ -30,24 +29,24 @@ async def test_attr_r_update_no_trace_logs_when_tracing_disabled(loguru_caplog): @pytest.mark.asyncio async def test_attr_r_update_logs_validation_error(loguru_caplog): - attr = AttrR(Int()) + attr = AttrR(int) with pytest.raises(ValueError): - await attr.update("not_an_int") + await attr.update("not_an_int") # type: ignore[arg-type] assert "Failed to validate value" in loguru_caplog.text @pytest.mark.asyncio async def test_attr_r_update_logs_callback_failure(loguru_caplog): - attr = AttrR(Int()) + attr = AttrR(int) async def failing_callback(_value: int): raise RuntimeError("callback failed") - attr.add_on_update_callback(failing_callback) + attr.add_readback_callback(failing_callback) with pytest.raises(RuntimeError): await attr.update(42) - assert "On update callbacks failed" in loguru_caplog.text + assert "Readback callbacks failed" in loguru_caplog.text diff --git a/tests/test_attributes.py b/tests/test_attributes.py index cdd428911..0a0b0d061 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,37 +1,51 @@ import asyncio -from dataclasses import dataclass +import time from functools import partial -from typing import Generic, TypeVar +import numpy as np +import numpy.typing as npt import pytest from pytest_mock import MockerFixture -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import ( + AttrR, + AttrRW, + AttrW, + NotPolled, + Polled, + Severity, + Update, +) from fastcs.controllers import Controller -from fastcs.datatypes import Float, Int, String +from fastcs.datatypes import ( + DEFAULT_ARRAY_SHAPE, + Array1D, + Limits, + Meta, + NumericLimits, + Table, +) +from fastcs.util import ONCE def test_attribute_access_mode(): """Test that attributes have the correct access_mode property.""" - attr_r = AttrR(String()) + attr_r = AttrR(str) assert attr_r.access_mode == "r" - attr_w = AttrW(String()) + attr_w = AttrW(str) assert attr_w.access_mode == "w" - attr_rw = AttrRW(String()) + attr_rw = AttrRW(str) assert attr_rw.access_mode == "rw" def test_attr_r(): - attr = AttrR(String(), group="test group") + attr = AttrR(str, group="test group") - with pytest.raises(RuntimeError): - _ = attr.io_ref - - assert not attr.has_io_ref() - assert isinstance(attr.datatype, String) - assert attr.dtype == str + assert not attr.has_getter() + assert attr.poll_period is None + assert attr.dtype is str assert attr.group == "test group" assert attr.name == "" assert attr.path == [] @@ -42,49 +56,133 @@ def test_attr_r(): assert attr.name == "test_name" assert attr.path == ["test_path"] - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="already registered with a controller as"): attr.set_name("test_name") - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="already registered with a controller at"): attr.set_path(["test_path"]) - assert attr.get() == "" + assert attr.readback == "" + + +def test_datatype_inferred_from_getter_annotation(): + async def get_value() -> float: + return 1.5 + + attr = AttrR(getter=get_value) + assert attr.dtype is float + + +def test_datatype_inferred_from_setter_annotation(): + async def set_value(value: int) -> None: + pass + + attr = AttrW(setter=set_value) + assert attr.dtype is int + + +def test_datatype_required_when_not_inferable(): + expected_message = "datatype must be given explicitly" + + with pytest.raises(ValueError, match=expected_message): + AttrR() + + with pytest.raises(ValueError, match=expected_message): + AttrW() + + with pytest.raises(ValueError, match=expected_message): + AttrRW() @pytest.mark.asyncio -async def test_attr_update(mocker: MockerFixture): - attr = AttrRW(Int()) +async def test_attr_update(): + attr = AttrRW(int) await attr.update(42) - assert attr.get() == 42 + assert attr.readback == 42 await attr.update("100") # type: ignore - assert attr.get() == 100 + assert attr.readback == 100 with pytest.raises(ValueError, match="Failed to cast"): await attr.update("not_an_int") # type: ignore - attr = AttrRW(Int()) - sync_setpoint_mock = mocker.AsyncMock() - attr.add_sync_setpoint_callback(sync_setpoint_mock) + # update() also accepts an Update wrapper, unwrapping to just the value + await attr.update(Update(7, timestamp=123.0)) + assert attr.readback == 7 + + +@pytest.mark.asyncio +async def test_poll(): + async def do_update(): + return 5 + + attr = AttrR(int, getter=do_update) + assert attr.has_getter() + + value = await attr.poll() + assert value == 5 + assert attr.readback == 5 - await attr.update("200") # type: ignore - assert attr.get() == 200 - sync_setpoint_mock.assert_called_once_with(200) - sync_setpoint_mock.reset_mock() - await attr.update(20) - assert attr.get() == 20 - sync_setpoint_mock.assert_not_called() +@pytest.mark.asyncio +async def test_poll_unwraps_update_wrapper(): + async def do_update(): + return Update(9, timestamp=123.0) + + attr = AttrR(int, getter=do_update) + value = await attr.poll() + assert value == 9 + assert attr.readback == 9 + + +@pytest.mark.asyncio +async def test_poll_with_no_getter_raises(): + attr = AttrR(int) + + with pytest.raises(RuntimeError, match="has no getter"): + await attr.poll() + + +@pytest.mark.asyncio +async def test_poll_exception_propagates(): + async def do_update(): + raise ValueError("do_update failed") + + attr = AttrR(int, getter=do_update) + + with pytest.raises(ValueError, match="do_update failed"): + await attr.poll() + + +def test_poll_period_comes_from_the_getter(): + async def do_update(): + return 1 + + # A bare getter is read once, when the controller connects. + attr = AttrR(int, getter=do_update) + assert attr.poll_period == ONCE + + # Wrapping it in Polled schedules it instead. + attr_explicit = AttrR(int, getter=Polled(do_update, period=0.5)) + assert attr_explicit.poll_period == 0.5 + + # NotPolled is never scheduled - on-demand poll() only. + attr_on_demand = AttrR(int, getter=NotPolled(do_update)) + assert attr_on_demand.poll_period is None + assert attr_on_demand.has_getter() + + attr_no_getter = AttrR(int) + assert attr_no_getter.poll_period is None @pytest.mark.asyncio async def test_wait_for_predicate(mocker: MockerFixture): - attr = AttrR(Int(), initial_value=0) + attr = AttrR(int, initial_value=0) async def update(attr: AttrR): while True: await asyncio.sleep(0.1) - await attr.update(attr.get() + 3) # 3, 6, 9, 12 != 10 + await attr.update(attr.readback + 3) # 3, 6, 9, 12 != 10 asyncio.create_task(update(attr)) @@ -93,7 +191,7 @@ def predicate(v: int) -> bool: return v > 10 wait_mock = mocker.spy(asyncio, "wait_for") - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError, match="Timeout waiting 0.2s for .* predicate"): await attr.wait_for_predicate(predicate, timeout=0.2) await attr.wait_for_predicate(predicate, timeout=1) @@ -107,7 +205,7 @@ def predicate(v: int) -> bool: @pytest.mark.asyncio async def test_wait_for_value(mocker: MockerFixture): - attr = AttrR(Int(), initial_value=0) + attr = AttrR(int, initial_value=0) async def update(attr: AttrR): await asyncio.sleep(0.5) @@ -116,7 +214,7 @@ async def update(attr: AttrR): asyncio.create_task(update(attr)) wait_mock = mocker.spy(asyncio, "wait_for") - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError, match="Timeout waiting 0.2s for .* value 10"): await attr.wait_for_value(10, timeout=0.2) await attr.wait_for_value(1, timeout=1) @@ -131,20 +229,18 @@ async def update(attr: AttrR): @pytest.mark.asyncio async def test_attributes(): device = {"state": "Idle", "number": 1, "count": False} - ui = {"state": "", "number": 0, "count": False, "update_count": 0} + ui = {"state": "", "number": 0, "update_count": 0} async def update_ui(value, key): ui[key] = value ui["update_count"] += 1 - async def send(_attr, value, key): + async def send(value, key): device[key] = value + return value # accepted value echoes straight back to the readback - async def device_add(): - device["number"] += 1 - - attr_r = AttrR(String()) - attr_r.add_on_update_callback(partial(update_ui, key="state"), always=False) + attr_r = AttrR(str) + attr_r.add_readback_callback(partial(update_ui, key="state"), always=False) await attr_r.update(device["state"]) assert ui["state"] == "Idle" # Update with new value triggers callback @@ -153,50 +249,83 @@ async def device_add(): # Identical update does not trigger callback as always=False assert ui["update_count"] == 1 - attr_rw = AttrRW(Int()) - attr_rw._on_put_callback = partial(send, key="number") - attr_rw.add_sync_setpoint_callback(partial(update_ui, key="number")) - await attr_rw.put(2, sync_setpoint=True) + attr_rw = AttrRW(int, setter=partial(send, key="number")) + attr_rw.add_readback_callback(partial(update_ui, key="number")) + await attr_rw.set(2) assert device["number"] == 2 assert ui["number"] == 2 @pytest.mark.asyncio -async def test_attribute_io(): - @dataclass - class MyAttributeIORef(AttributeIORef): - cool: int +async def test_soft_attribute_self_wires(): + """With no getter/setter, AttrRW.set() pushes straight to readback.""" + attr = AttrRW(int) + assert not attr.has_getter() + assert not attr.has_setter() + + await attr.set(40) + assert attr.setpoint == 40 + assert attr.readback == 40 + - class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): - async def update(self, attr: AttrR[int, MyAttributeIORef]): - print("I am updating", self.ref_type, attr.io_ref.cool) +@pytest.mark.asyncio +async def test_setter_return_value_updates_readback(): + accepted = {} - class MyController(Controller): - my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) - your_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=10)) + async def setter(value): + accepted["value"] = value + return value + 1 # device clamps/accepts a different value - def __init__(self): - super().__init__(ios=[MyAttributeIO()]) + attr = AttrRW(int, setter=setter) - c = MyController() + await attr.set(10) + assert accepted["value"] == 10 + assert attr.setpoint == 11 + assert attr.readback == 11 - class ControllerNoIO(Controller): - my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) - @dataclass - class OtherAttributeIORef(AttributeIORef): - not_cool: int +@pytest.mark.asyncio +async def test_setter_with_no_return_leaves_readback_untouched(): + async def setter(value): + return None - class MissingIOController(Controller): - my_attr = AttrR(Int(), io_ref=OtherAttributeIORef(not_cool=5)) + attr = AttrRW(int, setter=setter) - with pytest.raises(ValueError, match="does not have an AttributeIO to handle"): - controller = MissingIOController() - controller._connect_attribute_ios() + await attr.set(5) + assert attr.setpoint == 5 + assert attr.readback == 0 # unchanged - no getter/poll has happened + + +@pytest.mark.asyncio +async def test_attrw_setter_return_value_updates_setpoint_cache(): + async def setter(value): + return value + 1 + + attr = AttrW(int, setter=setter) + + await attr.set(5) + assert attr.setpoint == 6 - await c.initialise() - c._connect_attribute_ios() - await c.my_attr.bind_update_callback()() + +@pytest.mark.asyncio +async def test_set_setter_exception_is_caught_and_logged(mocker: MockerFixture): + async def do_set(value): + raise ValueError("do_set failed") + + attr = AttrW(int, setter=do_set) + mock_logger = mocker.patch("fastcs.attributes.attr_w.logger") + + # exception is caught, not raised, and the setpoint is still cached + await attr.set(5) + assert attr.setpoint == 5 + + # the setter's exception is the one logged, at error level + logged_exception = mock_logger.opt.call_args.kwargs["exception"] + assert isinstance(logged_exception, ValueError) + assert str(logged_exception) == "do_set failed" + mock_logger.opt.return_value.error.assert_called_once_with( + "Set failed", attribute=attr, setpoint=5 + ) class DummyConnection: @@ -264,38 +393,8 @@ async def set(self, uri: str, value: float | int): self._float_value = value -NumberT = TypeVar("NumberT", int, float) - - @pytest.mark.asyncio() -async def test_dynamic_attribute_io_specification(): - @dataclass - class DemoParameterAttributeIORef(AttributeIORef, Generic[NumberT]): - name: str - subsystem: str - connection: DummyConnection - - @property - def uri(self): - return f"{self.subsystem}/{self.name}" - - class DemoParameterAttributeIO(AttributeIO[NumberT, DemoParameterAttributeIORef]): - async def update( - self, - attr: AttrR[NumberT, DemoParameterAttributeIORef], - ): - value = await attr.io_ref.connection.get(attr.io_ref.uri) - await attr.update(value) # type: ignore - - async def send( - self, - attr: AttrW[NumberT, DemoParameterAttributeIORef], - value: NumberT, - ) -> None: - await attr.io_ref.connection.set(attr.io_ref.uri, value) - if isinstance(attr, AttrRW): - await self.update(attr) - +async def test_dynamic_attribute_getter_setter_specification(): class DemoParameterController(Controller): ro_int_parameter: AttrR int_parameter: AttrRW @@ -304,7 +403,7 @@ class DemoParameterController(Controller): async def initialise(self): self._connection = DummyConnection() await self._connection.connect() - dtype_mapping = {"int": Int, "float": Float} + dtype_mapping = {"int": int, "float": float} example_introspection_response = await self._connection.get( "config/introspect_api" ) @@ -312,22 +411,41 @@ async def initialise(self): for parameter_response in example_introspection_response: try: ro = parameter_response["read_only"] - ref = DemoParameterAttributeIORef( - name=parameter_response["name"], - subsystem=parameter_response["subsystem"], - connection=self._connection, - ) - attr_class = AttrR if ro else AttrRW - attr = attr_class( - datatype=dtype_mapping[parameter_response["dtype"]]( - min=parameter_response.get("min", None), - max=parameter_response.get("max", None), - ), - io_ref=ref, - initial_value=parameter_response.get("value", None), + name = parameter_response["name"] + uri = f"{parameter_response['subsystem']}/{name}" + datatype = dtype_mapping[parameter_response["dtype"]] + limits = NumericLimits( + control=Limits( + low=parameter_response.get("min", None), + high=parameter_response.get("max", None), + ) ) - self.add_attribute(ref.name, attr) + async def getter(uri=uri) -> int | float: + return await self._connection.get(uri) # type: ignore[return-value] + + if ro: + attr = AttrR( + datatype, + getter=getter, + initial_value=parameter_response.get("value", None), + limits=limits, + ) + else: + + async def setter(value, uri=uri): + await self._connection.set(uri, value) + return value + + attr = AttrRW( + datatype, + getter=getter, + setter=setter, + initial_value=parameter_response.get("value", None), + limits=limits, + ) + + self.add_attribute(name, attr) except Exception as e: print( "Exception constructing attribute from parameter response:", @@ -335,130 +453,173 @@ async def initialise(self): e, ) - c = DemoParameterController(ios=[DemoParameterAttributeIO()]) + c = DemoParameterController() await c.initialise() - c._connect_attribute_ios() - await c.ro_int_parameter.bind_update_callback()() - assert c.ro_int_parameter.get() == 10 - await c.ro_int_parameter.bind_update_callback()() - assert c.ro_int_parameter.get() == 11 - await c.int_parameter.put(20) - assert c.int_parameter.get() == 20 + assert await c.ro_int_parameter.poll() == 10 + assert await c.ro_int_parameter.poll() == 11 + await c.int_parameter.set(20) + assert c.int_parameter.readback == 20 -@pytest.mark.asyncio -async def test_attribute_no_io(mocker: MockerFixture): - class MyController(Controller): - no_ref = AttrRW(Int()) - base_class_ref = AttrRW(Int(), io_ref=AttributeIORef()) - - with pytest.raises( - ValueError, - match="MyController does not have an AttributeIO to handle AttributeIORef", - ): - c = MyController() - c._connect_attribute_ios() - - class SimpleAttributeIO(AttributeIO[int]): - async def update(self, attr): - await attr.update(100) - - with pytest.raises( - RuntimeError, match="More than one AttributeIO class handles AttributeIORef" - ): - MyController(ios=[SimpleAttributeIO(), SimpleAttributeIO()]) - - # we need to explicitly pass an AttributeIO if we want to handle instances of - # the AttributeIORef base class - c = MyController(ios=[SimpleAttributeIO()]) - assert not c.no_ref.has_io_ref() - assert c.base_class_ref.has_io_ref() - await c.initialise() - c._connect_attribute_ios() +@pytest.mark.parametrize("value", [3, Update(readback=3)], ids=["bare", "Update"]) +@pytest.mark.asyncio +async def test_a_value_with_no_timestamp_is_stamped_when_it_arrived(value): + attr = AttrR(int) + before = time.time() - # There is a difference between providing an AttributeIO for the default - # AttributeIORef class and not specifying the io_ref for an Attribute - # default callbacks are not provided by AttributeIO subclasses + await attr.update(value) - sync_setpoint_mock = mocker.AsyncMock() - c.no_ref.add_sync_setpoint_callback(sync_setpoint_mock) + assert before <= attr.timestamp <= time.time() - await c.no_ref.put(40) - sync_setpoint_mock.assert_called_once_with(40) # sync setpoint called on first set - sync_setpoint_mock.reset_mock() - await c.no_ref.put(41) # sync setpoint callback not called without flag - await c.no_ref.put(42, sync_setpoint=True) - sync_setpoint_mock.assert_called_once_with(42) - c2 = MyController(ios=[SimpleAttributeIO()]) +@pytest.mark.parametrize("value", [3, Update(readback=3)], ids=["bare", "Update"]) +@pytest.mark.asyncio +async def test_a_value_with_no_severity_is_reported_as_no_alarm(value): + attr = AttrR(int) - await c2.initialise() - c2._connect_attribute_ios() + await attr.update(value) - assert c2.base_class_ref.get() == 0 - await c2.base_class_ref.bind_update_callback()() - assert c2.base_class_ref.get() == 100 + assert attr.severity is Severity.NO_ALARM -def test_add_update_callback_twice_raises(): - async def do_update(attr: AttrR[int]): - pass +@pytest.mark.asyncio +async def test_an_update_can_carry_the_time_the_value_was_obtained(): + attr = AttrR(int) - attr = AttrRW(Int()) - attr.set_update_callback(do_update) + await attr.update(Update(readback=3, timestamp=1234.5)) - with pytest.raises(RuntimeError): - attr.set_update_callback(do_update) + assert attr.timestamp == 1234.5 @pytest.mark.asyncio -async def test_bind_update(): - attr = AttrRW(Int()) +async def test_an_update_can_carry_a_severity(): + attr = AttrR(int) - with pytest.raises(RuntimeError): - attr.bind_update_callback() + await attr.update(Update(readback=3, severity=Severity.MAJOR)) - async def do_update(attr: AttrR[int]): - await attr.update(5) + assert attr.severity is Severity.MAJOR - attr.set_update_callback(do_update) - callback = attr.bind_update_callback() - await callback() - assert attr.get() == 5 +@pytest.mark.asyncio +async def test_a_getter_can_report_a_timestamp_and_severity(): + async def get_value() -> Update[int]: + return Update(readback=7, timestamp=99.0, severity=Severity.MINOR) + attr = AttrR(int, getter=get_value) -@pytest.mark.asyncio -async def test_bind_update_exception(): - attr = AttrRW(Int()) + assert await attr.poll() == 7 + assert attr.timestamp == 99.0 + assert attr.severity is Severity.MINOR - async def do_update(attr: AttrR[int]): - raise ValueError("do_update failed") - attr.set_update_callback(do_update) +@pytest.mark.asyncio +async def test_a_rejected_value_leaves_the_timestamp_alone(): + """The cached value and the time it was obtained must agree.""" + attr = AttrRW(int, limits=NumericLimits(control=Limits(low=0)), getter=None) - callback = attr.bind_update_callback() + await attr.update(Update(readback=1, timestamp=10.0)) with pytest.raises(ValueError): - await callback() + await attr.update(Update(readback=-1, timestamp=20.0)) + + assert attr.readback == 1 + assert attr.timestamp == 10.0 + + +def test_metadata_is_held_on_the_attribute(): + attr = AttrRW(float, precision=3, units="degC", description="the temperature") + + assert attr.dtype is float + assert attr.meta == { + "precision": 3, + "units": "degC", + "description": "the temperature", + } + assert attr.description == "the temperature" + + +def test_metadata_the_datatype_has_no_use_for_is_rejected(): + # Also a static error - the constructor overloads unpack StrMeta for a str + # attribute - but the runtime check is what catches metadata arriving from a + # source the type checker never saw. + with pytest.raises(TypeError, match="'precision' is not valid metadata for str"): + AttrR(str, precision=3) # pyright: ignore[reportCallIssue, reportArgumentType] + + +def test_metadata_is_validated_when_replaced(): + attr = AttrR(int, units="counts") + + attr.update_meta(Meta(units="mm")) + assert attr.meta == {"units": "mm"} + + with pytest.raises(TypeError, match="'length' is not valid metadata for int"): + attr.update_meta(Meta(length=4)) + + +def test_update_meta_notifies_callbacks(): + attr = AttrR(int) + seen: list[Meta] = [] + attr.add_update_meta_callback(seen.append) + + attr.update_meta(Meta(units="mm")) + + assert seen == [{"units": "mm"}] + + +def test_array_element_type_comes_from_the_datatype(): + attr = AttrR(Array1D[np.int32], shape=(4,)) + + assert attr.dtype is np.ndarray + assert attr.meta.get("array_dtype") is np.int32 + assert np.array_equal(attr.readback, np.zeros(4, dtype=np.int32)) + + +def test_an_array_needs_neither_shape_nor_array_dtype(): + attr = AttrR(npt.NDArray[np.int32]) + + assert attr.dtype is np.ndarray + assert attr.meta.get("array_dtype") is np.int32 + assert attr.readback.dtype == np.int32 + assert attr.readback.shape == DEFAULT_ARRAY_SHAPE + + +def test_array_element_type_is_not_given_twice(): + with pytest.raises(TypeError, match="already given by the datatype subscript"): + AttrR(Array1D[np.int32], array_dtype=np.int64) + + +def test_a_table_needs_its_columns(): + with pytest.raises(TypeError, match="Table attribute needs its columns"): + AttrR(Table) + + +def test_structured_dtype_needs_a_table_datatype(): + with pytest.raises(TypeError, match="only valid for a Table attribute"): + # Statically an error too: structured_dtype belongs to TableMeta. + AttrR( # pyright: ignore[reportCallIssue] + Array1D[np.int32], # pyright: ignore[reportArgumentType] + structured_dtype=[("a", np.int32)], + ) @pytest.mark.asyncio -async def test_put(): - attr = AttrW(Int()) +async def test_control_limits_reject_out_of_range_values(): + attr = AttrRW(int, limits=NumericLimits(control=Limits(0, 10))) - async def do_put(attr: AttrW[int], value: int): - raise ValueError("do_put failed") + await attr.set(5) + assert attr.readback == 5 - async def do_sync_setpoint(setpoint: int): - raise ValueError("do_sync_setpoint failed") + with pytest.raises(ValueError, match="greater than maximum 10"): + await attr.update(15) - attr.set_on_put_callback(do_put) - attr.add_sync_setpoint_callback(do_sync_setpoint) - await attr.put(5) +@pytest.mark.asyncio +async def test_display_limits_do_not_reject_values(): + """Only the control range constrains a write; the rest are served, not enforced.""" + attr = AttrR(float, limits=NumericLimits(alarm=Limits(0.0, 10.0))) + + await attr.update(15.0) - with pytest.raises(RuntimeError): - attr.set_on_put_callback(do_put) + assert attr.readback == 15.0 diff --git a/tests/test_control_system.py b/tests/test_control_system.py index ca151cc02..19e3215e7 100644 --- a/tests/test_control_system.py +++ b/tests/test_control_system.py @@ -1,12 +1,10 @@ import asyncio -from dataclasses import dataclass import pytest -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.attributes import AttrR, NotPolled, Polled from fastcs.control_system import FastCS from fastcs.controllers import Controller -from fastcs.datatypes import Int from fastcs.methods import Command, command from fastcs.util import ONCE @@ -59,53 +57,56 @@ async def do_nothing_static(self): @pytest.mark.asyncio async def test_update_periods(): - @dataclass - class AttributeIORefTimesCalled(AttributeIORef): - update_period: float | None = None - _times_called = 0 + times_called = {"once": 0, "quickly": 0, "never": 0} - class AttributeIOTimesCalled(AttributeIO[int, AttributeIORefTimesCalled]): - async def update(self, attr: AttrR[int, AttributeIORefTimesCalled]): - attr.io_ref._times_called += 1 - await attr.update(attr.io_ref._times_called) + async def get_once(): + times_called["once"] += 1 + return times_called["once"] + + async def get_quickly(): + times_called["quickly"] += 1 + return times_called["quickly"] + + async def get_never(): + times_called["never"] += 1 + return times_called["never"] class MyController(Controller): - update_once = AttrR(Int(), io_ref=AttributeIORefTimesCalled(update_period=ONCE)) - update_quickly = AttrR( - Int(), io_ref=AttributeIORefTimesCalled(update_period=0.1) - ) - update_never = AttrR( - Int(), io_ref=AttributeIORefTimesCalled(update_period=None) - ) - - controller = MyController(ios=[AttributeIOTimesCalled()]) + def __init__(self): + super().__init__() + self.update_once = AttrR(int, getter=Polled(get_once, period=ONCE)) + self.update_quickly = AttrR(int, getter=Polled(get_quickly, period=0.1)) + self.update_never = AttrR(int, getter=NotPolled(get_never)) + + controller = MyController() loop = asyncio.get_event_loop() fastcs = FastCS(controller, [], loop) - assert controller.update_quickly.get() == 0 - assert controller.update_once.get() == 0 - assert controller.update_never.get() == 0 + assert controller.update_quickly.readback == 0 + assert controller.update_once.readback == 0 + assert controller.update_never.readback == 0 asyncio.create_task(fastcs.serve(interactive=False)) await asyncio.sleep(0.5) - assert controller.update_quickly.get() > 1 - assert controller.update_once.get() == 1 - assert controller.update_never.get() == 0 + assert controller.update_quickly.readback > 1 + assert controller.update_once.readback == 1 + assert controller.update_never.readback == 0 - assert len(fastcs._scan_tasks) == 1 - assert len(fastcs._initial_coros) == 1 + # One periodic scan task per distinct period, plus one reconnect watcher + assert len(fastcs._runner._scan_coros) == 1 + assert len(fastcs._runner._initial_coros) == 1 @pytest.mark.asyncio async def test_controller_connect_disconnect(): class MyTestController(Controller): async def connect(self): - self.connected = True + self.connect_called = True async def disconnect(self): - self.connected = False + self.connect_called = False controller = MyTestController() @@ -116,10 +117,10 @@ async def disconnect(self): # connect is called at the start of serve await asyncio.sleep(0.1) - assert controller.connected + assert controller.connect_called task.cancel() # disconnect is called at the end of serve await asyncio.sleep(0.1) - assert not controller.connected + assert not controller.connect_called diff --git a/tests/test_controller_runner.py b/tests/test_controller_runner.py new file mode 100644 index 000000000..b60bb7a64 --- /dev/null +++ b/tests/test_controller_runner.py @@ -0,0 +1,195 @@ +import asyncio +import sys + +import pytest + +from fastcs.attributes import AttrR +from fastcs.controllers import Controller, ControllerRunner +from fastcs.controllers.runner import RECONNECT_PERIOD +from fastcs.methods import scan +from fastcs.util import ONCE + + +class LifecycleController(Controller): + """Records every lifecycle hook the runner is supposed to call.""" + + def __init__(self): + super().__init__() + self.events: list[str] = [] + self.count = AttrR(int) + + async def initialise(self): + self.events.append("initialise") + + def post_initialise(self): + self.events.append("post_initialise") + + async def connect(self): + self.events.append("connect") + await super().connect() + + async def disconnect(self): + self.events.append("disconnect") + + @scan(ONCE) + async def read_once(self): + self.events.append("initial") + await self.count.update(self.count.readback + 1) + + +@pytest.mark.asyncio +async def test_the_runner_drives_the_whole_lifecycle(): + controller = LifecycleController() + runner = ControllerRunner(controller) + + await runner.start() + try: + assert controller.events == [ + "initialise", + "post_initialise", + "connect", + "initial", + ] + assert controller.count.readback == 1 + finally: + await runner.stop() + + assert controller.events[-1] == "disconnect" + + +@pytest.mark.asyncio +async def test_setup_builds_the_apis_before_anything_connects(): + """A transport is wired to the APIs between setup and start.""" + controller = LifecycleController() + runner = ControllerRunner(controller) + + apis = await runner.setup() + + assert [api.path for api in apis] == [[]] + assert "count" in apis[0].attributes + assert controller.events == ["initialise", "post_initialise"] + assert runner.controller_apis == apis + + +@pytest.mark.asyncio +async def test_start_sets_up_when_setup_has_not_run(): + runner = ControllerRunner(LifecycleController()) + + await runner.start() + try: + assert len(runner.controller_apis) == 1 + finally: + await runner.stop() + + +@pytest.mark.asyncio +async def test_a_runner_takes_several_controllers(): + controllers = [LifecycleController(), LifecycleController()] + runner = ControllerRunner(controllers) + + await runner.start() + try: + assert len(runner.controller_apis) == 2 + assert all(controller.count.readback == 1 for controller in controllers) + finally: + await runner.stop() + + +@pytest.mark.asyncio +async def test_stop_reports_a_failing_disconnect_without_raising(monkeypatch): + class UndisconnectableController(LifecycleController): + async def disconnect(self): + raise RuntimeError("no") + + logged: list[tuple[str, BaseException | None]] = [] + + def record_exception(event, **kwargs): + # ``logger.exception`` is called from the ``except`` block, so the + # exception it is reporting is the one currently being handled. + logged.append((event, sys.exc_info()[1])) + + monkeypatch.setattr("fastcs.controllers.runner.logger.exception", record_exception) + + runner = ControllerRunner(UndisconnectableController()) + await runner.start() + + await runner.stop() + + assert len(logged) == 1 + event, error = logged[0] + assert event == "Exception during disconnect" + assert isinstance(error, RuntimeError) + assert str(error) == "no" + + +@pytest.mark.asyncio +async def test_the_runner_reconnects_a_controller_that_dropped_out(monkeypatch): + """Nothing else calls reconnect, so a paused controller would stay paused.""" + monkeypatch.setattr("fastcs.controllers.runner.RECONNECT_PERIOD", 0.01) + + class DroppingController(LifecycleController): + reconnects = 0 + + async def reconnect(self): + self.reconnects += 1 + await super().reconnect() + + controller = DroppingController() + runner = ControllerRunner(controller) + await runner.start() + try: + assert controller.connected + + # What a scan task does when its callback raises + controller._connected = False + + await asyncio.sleep(0.05) + + assert controller.reconnects >= 1 + assert controller.connected + finally: + await runner.stop() + + +@pytest.mark.asyncio +async def test_a_failing_reconnect_does_not_stop_the_runner(monkeypatch): + monkeypatch.setattr("fastcs.controllers.runner.RECONNECT_PERIOD", 0.01) + + class UnreconnectableController(LifecycleController): + attempts = 0 + + async def reconnect(self): + self.attempts += 1 + raise RuntimeError("still down") + + controller = UnreconnectableController() + runner = ControllerRunner(controller) + await runner.start() + try: + controller._connected = False + await asyncio.sleep(0.05) + + # It keeps trying rather than dying on the first failure + assert controller.attempts > 1 + assert not controller.connected + finally: + await runner.stop() + + +@pytest.mark.asyncio +async def test_stop_cancels_the_tasks(): + controller = LifecycleController() + runner = ControllerRunner(controller) + await runner.start() + tasks = set(runner._tasks) + assert tasks + + await runner.stop() + await asyncio.sleep(0) + + assert all(task.cancelled() or task.done() for task in tasks) + assert not runner._tasks + + +def test_reconnect_period_is_a_second_by_default(): + assert RECONNECT_PERIOD == 1.0 diff --git a/tests/test_controllers.py b/tests/test_controllers.py index 5d5dfb4ed..c6d2a9ee9 100644 --- a/tests/test_controllers.py +++ b/tests/test_controllers.py @@ -5,7 +5,6 @@ from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Enum, Float, Int from fastcs.methods import Command, Scan, command, scan @@ -33,22 +32,22 @@ class SomeSubController(Controller): def __init__(self): super().__init__() - sub_attribute = AttrR(Int()) + sub_attribute = AttrR(int) - root_attribute = AttrR(Int()) + root_attribute = AttrR(int) class SomeController(Controller): annotated_attr_not_defined_in_init: AttrR[int] - equal_attr = AttrR(Int()) - annotated_and_equal_attr: AttrR[int] = AttrR(Int()) + equal_attr = AttrR(int) + annotated_and_equal_attr: AttrR[int] = AttrR(int) def __init__(self, sub_controller: Controller): super().__init__() - self.attr_on_object = AttrR(Int()) + self.attr_on_object = AttrR(int) - self.attributes["_attributes_attr"] = AttrR(Int()) + self.attributes["_attributes_attr"] = AttrR(int) self.attributes["_attributes_attr_equal"] = self.equal_attr self.sub_controller = sub_controller @@ -85,13 +84,13 @@ async def noop() -> None: @pytest.mark.parametrize( "member_name, member_value, expected_error", [ - ("attr", AttrR(Float()), r"Cannot add attribute"), + ("attr", AttrR(float), r"Cannot add attribute"), ("attr", Controller(), r"Cannot add sub controller"), ("attr", Command(noop), r"Cannot add command"), - ("sub_controller", AttrR(Int()), r"Cannot add attribute"), + ("sub_controller", AttrR(int), r"Cannot add attribute"), ("sub_controller", Controller(), r"Cannot add sub controller"), ("sub_controller", Command(noop), r"Cannot add command"), - ("cmd", AttrR(Int()), r"Cannot add attribute"), + ("cmd", AttrR(int), r"Cannot add attribute"), ("cmd", Controller(), r"Cannot add sub controller"), ("cmd", Command(noop), r"Cannot add command"), ], @@ -100,7 +99,7 @@ def test_conflicting_attributes_and_controllers_and_commands( member_name, member_value, expected_error ): class ConflictingController(Controller): - attr = AttrR(Int()) + attr = AttrR(int) cmd = Command(noop) def __init__(self): @@ -163,10 +162,10 @@ class HintedController(Controller): controller = HintedController() with pytest.raises(RuntimeError, match="does not match defined datatype"): - controller.add_attribute("read_write_int", AttrRW(Float())) + controller.add_attribute("read_write_int", AttrRW(float)) with pytest.raises(RuntimeError, match="does not match defined access mode"): - controller.add_attribute("read_write_int", AttrR(Int())) + controller.add_attribute("read_write_int", AttrR(int)) with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"): controller.read_write_int = 5 # type: ignore @@ -175,7 +174,7 @@ class HintedController(Controller): with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"): controller._validate_type_hints() - controller.add_attribute("read_write_int", AttrRW(Int())) + controller.add_attribute("read_write_int", AttrRW(int)) def test_enum_attribute_hint_validation(): @@ -191,9 +190,9 @@ class HintedController(Controller): controller = HintedController() with pytest.raises(RuntimeError, match="does not match defined datatype"): - controller.add_attribute("enum", AttrRW(Enum(BadEnum))) + controller.add_attribute("enum", AttrRW(BadEnum)) - controller.add_attribute("enum", AttrRW(Enum(GoodEnum))) + controller.add_attribute("enum", AttrRW(GoodEnum)) @pytest.mark.asyncio @@ -233,12 +232,12 @@ class HintedController(Controller): def test_controller_api(): class MyTestController(Controller): - attr1: AttrRW[int] = AttrRW(Int()) + attr1: AttrRW[int] = AttrRW(int) def __init__(self): super().__init__(description="Controller for testing") - self.attr2 = AttrRW(Int()) + self.attr2 = AttrRW(int) @command() async def do_nothing(self): diff --git a/tests/test_datatypes.py b/tests/test_datatypes.py index b0b26d562..6040421dd 100644 --- a/tests/test_datatypes.py +++ b/tests/test_datatypes.py @@ -1,115 +1,198 @@ -from enum import IntEnum +from enum import Enum, IntEnum import numpy as np +import numpy.typing as npt import pytest -from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, Table, Waveform -from fastcs.datatypes._util import numpy_to_fastcs_datatype +from fastcs.datatypes import ( + Array1D, + Limits, + Meta, + NumericLimits, + Table, + default_value, + numpy_to_python_type, + resolve_datatype, + validate_meta, + validate_value, + values_equal, +) + + +class Colour(Enum): + RED = "red" -def test_base_validate(): - class TestInt(DataType[int]): - @property - def dtype(self) -> type[int]: - return int +_TABLE_META = Meta( + structured_dtype=[("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))] +) + +def test_coerces_to_the_datatype(): class MyIntEnum(IntEnum): A = 0 B = 1 - test_int = TestInt() - - assert test_int.validate("0") == 0 - assert test_int.validate(MyIntEnum.B) == 1 + assert validate_value(int, Meta(), "0") == 0 + assert validate_value(int, Meta(), MyIntEnum.B) == 1 with pytest.raises(ValueError, match="Failed to cast"): - test_int.validate("foo") + validate_value(int, Meta(), "foo") @pytest.mark.parametrize( - ["datatype", "init_args", "value"], + ["dtype", "meta", "value"], [ - (Int, {"min": 1}, 0), - (Int, {"max": -1}, 0), - (Float, {"min": 1}, 0.0), - (Float, {"max": -1}, 0.0), - (Enum, {"enum_cls": int}, 0), - (Waveform, {"array_dtype": "uint64", "shape": (1, 1)}, np.ndarray([1])), + (int, Meta(limits=NumericLimits(control=Limits(low=1))), 0), + (int, Meta(limits=NumericLimits(control=Limits(high=-1))), 0), + (float, Meta(limits=NumericLimits(control=Limits(low=1))), 0.0), + (float, Meta(limits=NumericLimits(control=Limits(high=-1))), 0.0), + ( + np.ndarray, + Meta(array_dtype="uint64", shape=(1, 1)), + np.ndarray([1]), + ), ], ) -def test_validate(datatype, init_args, value): +def test_rejects_values_outside_the_metadata(dtype, meta, value): with pytest.raises(ValueError): - datatype(**init_args).validate(value) + validate_value(dtype, meta, value) + + +def test_control_limits_default_to_the_display_range(): + limits = NumericLimits(display=Limits(0.0, 10.0)) + + assert limits.control == Limits(0.0, 10.0) + with pytest.raises(ValueError, match="less than minimum"): + validate_value(float, Meta(limits=limits), -1.0) + + +def test_warning_limits_default_to_the_alarm_range(): + assert NumericLimits(alarm=Limits(0, 10)).warning == Limits(0, 10) + + +def test_warning_limits_must_lie_within_the_alarm_range(): + with pytest.raises(ValueError, match="not within alarm limits"): + NumericLimits(alarm=Limits(0, 10), warning=Limits(-1, 11)) @pytest.mark.parametrize( - "numpy_type, fastcs_datatype", + "numpy_type, python_type", [ - (np.float16, Float()), - (np.float32, Float()), - (np.int16, Int()), - (np.int32, Int()), - (np.bool, Bool()), - (np.dtype("S1000"), String()), - (np.dtype("U25"), String()), - (np.dtype(">i4"), Int()), - (np.dtype("d"), Float()), + (np.float16, float), + (np.float32, float), + (np.int16, int), + (np.int32, int), + (np.bool, bool), + (np.dtype("S1000"), str), + (np.dtype("U25"), str), + (np.dtype(">i4"), int), + (np.dtype("d"), float), ], ) -def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype): - assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type) +def test_numpy_to_python_type(numpy_type, python_type): + assert numpy_to_python_type(numpy_type) is python_type @pytest.mark.parametrize( - "fastcs_datatype, value1, value2, expected", + "dtype, value1, value2, expected", [ - (Int(), 1, 1, True), - (Int(), 1, 2, False), - (Float(), 1.0, 1.0, True), - (Float(), 1.0, 2.0, False), - (Bool(), True, True, True), - (Bool(), True, False, False), - (String(), "foo", "foo", True), - (String(), "foo", "bar", False), - (Waveform(np.int16), np.array([1]), np.array([1]), True), - (Waveform(np.int16), np.array([1]), np.array([2]), False), + (int, 1, 1, True), + (int, 1, 2, False), + (float, 1.0, 1.0, True), + (float, 1.0, 2.0, False), + (bool, True, True, True), + (bool, True, False, False), + (str, "foo", "foo", True), + (str, "foo", "bar", False), + (np.ndarray, np.array([1]), np.array([1]), True), + (np.ndarray, np.array([1]), np.array([2]), False), ( - Table([("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))]), + np.ndarray, np.array([1, True, "foo"]), np.array([1, True, "foo"]), True, ), ( - Table([("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))]), + np.ndarray, np.array([1, True, "foo"]), np.array([2, False, "bar"]), False, ), ], ) -def test_dataset_equal(fastcs_datatype: DataType, value1, value2, expected): - assert fastcs_datatype.equal(value1, value2) is expected +def test_values_equal(dtype, value1, value2, expected): + assert values_equal(dtype, value1, value2) is expected + + +def test_string_length(): + assert validate_value(str, Meta(length=10), "12345678901") == "1234567890" + assert validate_value(str, Meta(), "12345678901") == "12345678901" + + with pytest.raises(ValueError, match="String length must be >= 1"): + validate_meta(str, Meta(length=0)) + + +def test_float_is_rounded_to_its_precision(): + assert validate_value(float, Meta(precision=3), 1.23456) == 1.235 + assert validate_value(float, Meta(), 1.23456) == 1.23 @pytest.mark.parametrize( - "fastcs_datatype, values, expected", + "spelling, dtype, element_type", [ - (Int(), [1, 1], True), - (Int(), [1, 2], False), - (Float(), [1.0, 1.0], True), - (Float(), [1.0, 2.0], False), - (Bool(), [True, True], True), - (Bool(), [True, False], False), + (int, int, None), + (float, float, None), + (bool, bool, None), + (str, str, None), + (Array1D[np.int32], np.ndarray, np.int32), + # numpy's own alias for a subscripted array is the same spelling with an + # unbounded shape, so it resolves the same way. + (npt.NDArray[np.int32], np.ndarray, np.int32), + (np.ndarray, np.ndarray, None), + (Table, np.ndarray, None), + (Colour, Colour, None), ], ) -def test_dataset_all_equal(fastcs_datatype: DataType, values, expected): - assert fastcs_datatype.all_equal(values) is expected +def test_resolve_datatype(spelling, dtype, element_type): + assert resolve_datatype(spelling) == (dtype, element_type) -def test_string_length(): - assert String(length=10).validate("12345678901") == "1234567890" +@pytest.mark.parametrize("spelling", ["float", 3, list[int]]) +def test_resolve_datatype_rejects_unsupported_spellings(spelling): + with pytest.raises(TypeError): + resolve_datatype(spelling) - assert String().validate("12345678901") == "12345678901" - with pytest.raises(ValueError): - String(length=0) +@pytest.mark.parametrize( + "dtype, meta, expected", + [ + (int, Meta(), 0), + (float, Meta(), 0.0), + (bool, Meta(), False), + (str, Meta(), ""), + ], +) +def test_default_value(dtype, meta, expected): + assert default_value(dtype, meta) == expected + + +def test_default_value_of_an_array(): + assert np.array_equal( + default_value(np.ndarray, Meta(array_dtype=np.int32, shape=(3,))), + np.zeros(3, dtype=np.int32), + ) + + +def test_default_value_of_a_table(): + assert default_value(np.ndarray, _TABLE_META).size == 0 + + +def test_validate_meta_rejects_fields_the_datatype_has_no_use_for(): + with pytest.raises(TypeError, match="'precision' is not valid metadata for str"): + validate_meta(str, Meta(precision=3), "device_id") + + +def test_an_array_needs_an_element_type(): + with pytest.raises(TypeError, match="needs an element type"): + default_value(np.ndarray, Meta(shape=(3,))) diff --git a/tests/test_launch.py b/tests/test_launch.py index 17e935f4c..df7bdae58 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -13,7 +13,6 @@ from fastcs.attributes import AttrR from fastcs.control_system import FastCS from fastcs.controllers import Controller -from fastcs.datatypes import Int from fastcs.exceptions import LaunchError from fastcs.launch import ( _build_options_model, @@ -45,7 +44,7 @@ def __init__(self, arg): class IsHinted(Controller): - read = AttrR(Int()) + read = AttrR(int) def __init__(self, arg: SomeConfig) -> None: super().__init__() diff --git a/tests/test_methods.py b/tests/test_methods.py index a3e990996..b0d9681a5 100644 --- a/tests/test_methods.py +++ b/tests/test_methods.py @@ -1,3 +1,5 @@ +from inspect import signature + import pytest from fastcs.controllers import Controller @@ -14,12 +16,6 @@ def sync_do_nothing(): with pytest.raises(TypeError): Method(sync_do_nothing) # type: ignore - async def do_nothing_with_return() -> int: - return 1 - - with pytest.raises(TypeError): - Method(do_nothing_with_return) # type: ignore - async def do_nothing(): """Do nothing.""" pass @@ -28,6 +24,21 @@ async def do_nothing(): assert method.docstring == "Do nothing." assert method.group == "Nothing" + assert method.signature == signature(do_nothing) + + +def test_a_scan_takes_no_arguments_and_returns_nothing(): + async def scan_with_return() -> int: + return 1 + + with pytest.raises(TypeError, match="Scan method return type must be None"): + Scan(scan_with_return, 1.0) # type: ignore + + async def scan_with_argument(arg: int): + pass + + with pytest.raises(TypeError, match="Scan method cannot have arguments"): + Scan(scan_with_argument, 1.0) # type: ignore @pytest.mark.asyncio @@ -75,3 +86,93 @@ async def update_nothing_with_arg(self, arg): assert scan.period == 1.0 await scan() + + +@pytest.mark.asyncio +async def test_a_command_can_take_arguments_and_return_a_value(): + class TestController(Controller): + async def move_to(self, position: float, wait: bool) -> str: + return f"moved to {position}, waited {wait}" + + command = UnboundCommand(TestController.move_to).bind(TestController()) + + assert command.argument_types == (float, bool) + assert command.return_datatype is str + assert not command.is_void + assert await command(1.5, True) == "moved to 1.5, waited True" + + +@pytest.mark.asyncio +async def test_a_void_command_says_so(): + class TestController(Controller): + async def stop(self): + pass + + command = UnboundCommand(TestController.stop).bind(TestController()) + + assert command.argument_types == () + assert command.return_datatype is None + assert command.is_void + + +def test_command_arguments_must_be_annotated(): + class TestController(Controller): + async def move_to(self, position): + pass + + with pytest.raises(TypeError, match="Argument 'position'.*has no type annotation"): + UnboundCommand(TestController.move_to) + + +def test_command_arguments_must_be_a_supported_type(): + class TestController(Controller): + async def move_to(self, position: list[float]): + pass + + with pytest.raises(TypeError, match="Argument 'position'.*unsupported type"): + UnboundCommand(TestController.move_to) + + +def test_command_return_must_be_a_supported_type(): + class TestController(Controller): + async def measure(self) -> list[float]: + return [] + + with pytest.raises(TypeError, match="Return value.*unsupported type"): + UnboundCommand(TestController.measure) + + +def test_command_arguments_are_positional(): + class TestController(Controller): + async def move_to(self, *, position: float): + pass + + with pytest.raises(TypeError, match="keyword-only argument 'position'"): + UnboundCommand(TestController.move_to) + + +def test_command_arguments_must_be_fully_known(): + class TestController(Controller): + async def move_to(self, *args: float): + pass + + with pytest.raises(TypeError, match=r"takes \*args or \*\*kwargs"): + UnboundCommand(TestController.move_to) + + +@pytest.mark.asyncio +async def test_command_arguments_survive_binding(): + """The signature a transport reads must be the bound one, without ``self``.""" + + class TestController(Controller): + seen: list[float] = [] + + async def move_to(self, position: float) -> None: + self.seen.append(position) + + controller = TestController() + command = UnboundCommand(TestController.move_to).bind(controller) + + assert list(command.signature.parameters) == ["position"] + await command(2.5) + assert controller.seen == [2.5] diff --git a/tests/test_multi_controller.py b/tests/test_multi_controller.py index 9e27f62e5..1edf30f72 100644 --- a/tests/test_multi_controller.py +++ b/tests/test_multi_controller.py @@ -12,7 +12,6 @@ from fastcs.attributes import AttrR from fastcs.control_system import FastCS from fastcs.controllers import Controller -from fastcs.datatypes import Int from fastcs.transports.epics import EpicsDocsOptions, EpicsGUIOptions from fastcs.transports.epics.ca.transport import EpicsCATransport from fastcs.transports.epics.emission import INDEX_STEM @@ -26,11 +25,11 @@ class _IdController(Controller): class _OneAttrController(Controller): - foo = AttrR(Int()) + foo = AttrR(int) class _OtherAttrController(Controller): - bar = AttrR(Int()) + bar = AttrR(int) def test_controller_api_path_uses_id(): @@ -166,7 +165,7 @@ async def test_pva_transport_serves_two_controllers_with_distinct_pvi_roots(): transport = EpicsPVATransport() transport.connect([api1, api2], asyncio.get_event_loop()) - providers = await transport._ioc._build_providers() + providers = transport._ioc._providers pv_names = {name for provider in providers for name in provider.keys()} assert "ALPHA:PVI" in pv_names @@ -303,11 +302,11 @@ class names, so ``DEV-1`` and ``DEV_1`` would silently override each other in class _LifecycleController(Controller): """Records lifecycle hook calls for end-to-end assertions.""" - foo = AttrR(Int()) + foo = AttrR(int) def __init__(self): super().__init__() - self.connected = False + self.connect_called = False self.initialised = False self.post_initialised = False @@ -318,14 +317,14 @@ def post_initialise(self): self.post_initialised = True async def connect(self): - self.connected = True + self.connect_called = True async def disconnect(self): - self.connected = False + self.connect_called = False class _OtherLifecycleController(_LifecycleController): - bar = AttrR(Int()) + bar = AttrR(int) @pytest.mark.asyncio @@ -350,7 +349,7 @@ async def test_fastcs_serves_two_controllers_end_to_end(mocker: MockerFixture): for controller in (a, b): assert controller.initialised assert controller.post_initialised - assert controller.connected + assert controller.connect_called with TestClient(transport._server._app) as client: assert client.get("/alpha/foo").status_code == 200 @@ -370,4 +369,4 @@ async def test_fastcs_serves_two_controllers_end_to_end(mocker: MockerFixture): pass for controller in (a, b): - assert not controller.connected + assert not controller.connect_called diff --git a/tests/test_typed_commands.py b/tests/test_typed_commands.py new file mode 100644 index 000000000..c53a78afc --- /dev/null +++ b/tests/test_typed_commands.py @@ -0,0 +1,226 @@ +"""Serving commands that take arguments and return values, per transport. + +Each transport declares what it can carry: REST and GraphQL round-trip a typed +call, Tango carries at most one argument, and the EPICS transports are void-only +and skip anything else with a warning rather than refusing to serve the +controller (ADR 0015). +""" + +import asyncio +import enum + +import pytest +from fastapi.testclient import TestClient + +from fastcs.attributes import AttrR +from fastcs.controllers import Controller, ControllerAPI +from fastcs.methods import command +from fastcs.transports.epics.ca.ioc import EpicsCAIOC +from fastcs.transports.epics.gui import EpicsGUI +from fastcs.transports.epics.pva.ioc import parse_attributes +from fastcs.transports.graphql.transport import GraphQLTransport +from fastcs.transports.rest.transport import RestTransport +from fastcs.transports.tango.dsr import _collect_dev_commands, _unservable_reason + + +class TypedCommandController(Controller): + """A controller with one command of each shape.""" + + calls: list[tuple] = [] + + # The GraphQL transport refuses an API with nothing to read + position = AttrR(float) + + @command() + async def stop(self) -> None: + self.calls.append(()) + + @command() + async def move_to(self, position: float, wait: bool) -> None: + self.calls.append((position, wait)) + + @command() + async def measure(self) -> float: + return 1.5 + + @command() + async def scale(self, factor: float) -> float: + return factor * 2 + + +@pytest.fixture +def controller_api() -> ControllerAPI: + TypedCommandController.calls = [] + return TypedCommandController()._build_api(["DEVICE"]) + + +def rest_client(controller_api: ControllerAPI) -> TestClient: + transport = RestTransport() + transport.connect([controller_api], asyncio.AbstractEventLoop()) + return TestClient(transport._server._app) + + +class TestRest: + def test_void_command_answers_no_content(self, controller_api): + with rest_client(controller_api) as client: + assert client.put("/DEVICE/stop").status_code == 204 + + def test_arguments_are_taken_from_the_request_body(self, controller_api): + with rest_client(controller_api) as client: + response = client.put( + "/DEVICE/move-to", json={"position": 2.5, "wait": True} + ) + + assert response.status_code == 204 + assert TypedCommandController.calls == [(2.5, True)] + + def test_a_missing_argument_is_rejected(self, controller_api): + with rest_client(controller_api) as client: + response = client.put("/DEVICE/move-to", json={"position": 2.5}) + + assert response.status_code == 422 + assert TypedCommandController.calls == [] + + def test_return_value_comes_back_in_the_body(self, controller_api): + with rest_client(controller_api) as client: + response = client.put("/DEVICE/measure") + + assert response.status_code == 200 + assert response.json() == {"value": 1.5} + + def test_arguments_and_a_return_value_together(self, controller_api): + with rest_client(controller_api) as client: + response = client.put("/DEVICE/scale", json={"factor": 3.0}) + + assert response.status_code == 200 + assert response.json() == {"value": 6.0} + + +class TestGraphQL: + @pytest.fixture + def client(self, controller_api) -> TestClient: + transport = GraphQLTransport() + transport.connect([controller_api], asyncio.AbstractEventLoop()) + return TestClient(transport._server._app) + + def query(self, client: TestClient, mutation: str): + response = client.post("/graphql", json={"query": f"mutation {{ {mutation} }}"}) + assert response.status_code == 200 + body = response.json() + assert "errors" not in body, body["errors"] + return body["data"] + + def test_void_command_reports_that_it_ran(self, client): + assert self.query(client, "DEVICE { stop }") == {"DEVICE": {"stop": True}} + + def test_arguments_are_mutation_arguments(self, client): + assert self.query(client, "DEVICE { moveTo(position: 2.5, wait: true) }") == { + "DEVICE": {"moveTo": True} + } + assert TypedCommandController.calls == [(2.5, True)] + + def test_return_value_is_the_mutation_result(self, client): + assert self.query(client, "DEVICE { scale(factor: 3.0) }") == { + "DEVICE": {"scale": 6.0} + } + + +class TestEpicsCA: + def test_typed_commands_are_skipped_and_void_ones_are_not(self, controller_api): + """A typed command must not stop the void ones being served.""" + EpicsCAIOC([controller_api], aliases={}) + + assert { + name: method.enabled + for name, method in controller_api.command_methods.items() + } == { + "stop": True, + "move_to": False, + "measure": False, + "scale": False, + } + + def test_skipping_says_why(self, controller_api, loguru_caplog): + EpicsCAIOC([controller_api], aliases={}) + + assert ( + "EPICS CA transport cannot serve a command that takes arguments or " + "returns a value" in loguru_caplog.text + ) + + +class TestTango: + """Tango carries one argument at most, and no enum, so it declares that.""" + + def test_serves_a_void_command(self, controller_api): + assert _unservable_reason(controller_api.command_methods["stop"]) is None + + def test_serves_one_argument_and_a_return_value(self, controller_api): + assert _unservable_reason(controller_api.command_methods["scale"]) is None + + def test_refuses_more_than_one_argument(self, controller_api): + assert ( + _unservable_reason(controller_api.command_methods["move_to"]) + == "a Tango command takes at most one argument" + ) + + def test_refuses_a_datatype_it_cannot_carry(self): + class Colour(enum.Enum): + RED = "red" + + class EnumCommandController(Controller): + @command() + async def set_colour(self, colour: Colour) -> None: + pass + + api = EnumCommandController()._build_api(["DEVICE"]) + + assert ( + _unservable_reason(api.command_methods["set_colour"]) + == "Tango commands do not carry Colour" + ) + + +class TestEpicsPva: + @pytest.mark.asyncio + async def test_typed_commands_are_skipped_and_void_ones_are_not( + self, controller_api + ): + provider = parse_attributes(controller_api) + + assert "DEVICE:Stop" in provider.keys() + assert "DEVICE:MoveTo" not in provider.keys() + assert { + name: method.enabled + for name, method in controller_api.command_methods.items() + } == { + "stop": True, + "move_to": False, + "measure": False, + "scale": False, + } + + +class TestEpicsGui: + def test_a_command_the_ioc_skipped_gets_no_widget(self, controller_api): + """The IOC is built before the GUI, so a skipped command has said so.""" + EpicsCAIOC([controller_api], aliases={}) + + components = EpicsGUI(controller_api).extract_api_components(controller_api) + + assert [component.name for component in components] == ["Position", "Stop"] + + def test_a_disabled_attribute_gets_no_widget(self, controller_api): + controller_api.attributes["position"].enabled = False + + components = EpicsGUI(controller_api).extract_api_components(controller_api) + + assert "Position" not in [component.name for component in components] + + +class TestTangoCollection: + def test_only_servable_commands_are_collected(self, controller_api, mocker): + collection = _collect_dev_commands(controller_api, mocker.MagicMock()) + + assert sorted(collection) == ["Measure", "Scale", "Stop"] + assert not controller_api.command_methods["move_to"].enabled diff --git a/tests/transports/epics/ca/test_ca_util.py b/tests/transports/epics/ca/test_ca_util.py index 463a17dcd..6f56d2123 100644 --- a/tests/transports/epics/ca/test_ca_util.py +++ b/tests/transports/epics/ca/test_ca_util.py @@ -1,9 +1,11 @@ import enum +from typing import Any, cast import pytest +from fastcs.attributes import AttrR from fastcs.controllers import ControllerAPI -from fastcs.datatypes import Bool, Enum, Float, Int, String +from fastcs.datatypes import Meta from fastcs.transports.epics.ca.util import ( cast_from_epics_type, cast_to_epics_type, @@ -11,6 +13,18 @@ ) +def attr(datatype, **meta) -> AttrR: + """An attribute to cast values for, standing in for a real controller's.""" + return AttrR(datatype, **meta) + + +class UnsupportedAttribute: + """Stands in for an attribute of a datatype no transport knows about.""" + + dtype = object + meta: Meta = {} + + class ShortEnum(enum.Enum): NOT = 0 TOO = 1 @@ -65,74 +79,74 @@ class ShortMixedEnum(enum.Enum): @pytest.mark.parametrize( - "datatype,input,output", + "attribute,input,output", [ - (Enum(ShortEnum), ShortEnum.TOO, 1), + (attr(ShortEnum), ShortEnum.TOO, 1), # in CA, enums with too many values become epics strings - (Enum(LongMixedEnum), LongMixedEnum.BE, "BE"), # string value - (Enum(LongMixedEnum), LongMixedEnum.EPICS, "EPICS"), # None value - (Enum(LongMixedEnum), LongMixedEnum.MBB, "MBB"), # int value - (Int(), 4, 4), - (Float(), 1.0, 1.0), - (Bool(), True, True), - (String(), "a" * 257, "a" * 256), - (String(length=3), "1234", "123"), + (attr(LongMixedEnum), LongMixedEnum.BE, "BE"), # string value + (attr(LongMixedEnum), LongMixedEnum.EPICS, "EPICS"), # None value + (attr(LongMixedEnum), LongMixedEnum.MBB, "MBB"), # int value + (attr(int), 4, 4), + (attr(float), 1.0, 1.0), + (attr(bool), True, True), + (attr(str), "a" * 257, "a" * 256), + (attr(str, length=3), "1234", "123"), # shorter enums can be represented by integers from 0-15 - (Enum(ShortMixedEnum), ShortMixedEnum.STRING_MEMBER, 0), - (Enum(ShortMixedEnum), ShortMixedEnum.INT_MEMBER, 1), - (Enum(ShortMixedEnum), ShortMixedEnum.NONE_MEMBER, 2), + (attr(ShortMixedEnum), ShortMixedEnum.STRING_MEMBER, 0), + (attr(ShortMixedEnum), ShortMixedEnum.INT_MEMBER, 1), + (attr(ShortMixedEnum), ShortMixedEnum.NONE_MEMBER, 2), ], ) -def test_casting_to_epics(datatype, input, output): - assert cast_to_epics_type(datatype, input) == output +def test_casting_to_epics(attribute, input, output): + assert cast_to_epics_type(attribute, input) == output @pytest.mark.parametrize( - "datatype, input", + "attribute, input", [ - # TODO cover Waveform and Table cases - (Enum(ShortEnum), LongEnum.TOO), # wrong enum.Enum class + # TODO cover Array1D and Table cases + (attr(ShortEnum), LongEnum.TOO), # wrong enum.Enum class ], ) -def test_cast_to_epics_validations(datatype, input): +def test_cast_to_epics_validations(attribute, input): with pytest.raises(ValueError): - cast_to_epics_type(datatype, input) + cast_to_epics_type(attribute, input) @pytest.mark.parametrize( - "datatype,from_epics,result", + "attribute,from_epics,result", [ # long enums backed by strings - (Enum(LongMixedEnum), "BE", LongMixedEnum.BE), # string value - (Enum(LongMixedEnum), "EPICS", LongMixedEnum.EPICS), # None value - (Enum(LongMixedEnum), "MBB", LongMixedEnum.MBB), # int value - (Int(), 4, 4), - (Float(), 1.0, 1.0), - (Bool(), True, True), - (String(), "hey", "hey"), - (Enum(ShortEnum), 2, ShortEnum.MANY), + (attr(LongMixedEnum), "BE", LongMixedEnum.BE), # string value + (attr(LongMixedEnum), "EPICS", LongMixedEnum.EPICS), # None value + (attr(LongMixedEnum), "MBB", LongMixedEnum.MBB), # int value + (attr(int), 4, 4), + (attr(float), 1.0, 1.0), + (attr(bool), True, True), + (attr(str), "hey", "hey"), + (attr(ShortEnum), 2, ShortEnum.MANY), # short enums backed by mbbi/mbbo - (Enum(ShortMixedEnum), 0, ShortMixedEnum.STRING_MEMBER), - (Enum(ShortMixedEnum), 1, ShortMixedEnum.INT_MEMBER), - (Enum(ShortMixedEnum), 2, ShortMixedEnum.NONE_MEMBER), - (Bool(), 1, True), - (Bool(), 0, False), + (attr(ShortMixedEnum), 0, ShortMixedEnum.STRING_MEMBER), + (attr(ShortMixedEnum), 1, ShortMixedEnum.INT_MEMBER), + (attr(ShortMixedEnum), 2, ShortMixedEnum.NONE_MEMBER), + (attr(bool), 1, True), + (attr(bool), 0, False), ], ) -def test_cast_from_epics_type(datatype, from_epics, result): - assert cast_from_epics_type(datatype, from_epics) == result +def test_cast_from_epics_type(attribute, from_epics, result): + assert cast_from_epics_type(attribute, from_epics) == result @pytest.mark.parametrize( - "datatype, input", + "attribute, input", [ - (object(), 0), - (Bool(), 3), + (UnsupportedAttribute(), 0), + (attr(bool), 3), ], ) -def test_cast_from_epics_validations(datatype, input): +def test_cast_from_epics_validations(attribute, input): with pytest.raises(ValueError): - cast_from_epics_type(datatype, input) + cast_from_epics_type(cast(Any, attribute), input) @pytest.mark.parametrize("id", ["DEVICE", "my-id", "name_1", "ABC-123_xyz"]) diff --git a/tests/transports/epics/ca/test_gui.py b/tests/transports/epics/ca/test_gui.py index 46e000e15..b8586c469 100644 --- a/tests/transports/epics/ca/test_gui.py +++ b/tests/transports/epics/ca/test_gui.py @@ -22,7 +22,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerAPI -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D from fastcs.transports.epics.emission import INDEX_STEM, emit_gui_files from fastcs.transports.epics.gui import EpicsGUI from fastcs.transports.epics.options import EpicsGUIOptions @@ -37,50 +37,50 @@ def test_get_pv(): @pytest.mark.parametrize( - "datatype, widget", + "attribute, widget", [ - (Bool(), LED()), - (Int(), TextRead()), - (Float(), TextRead()), - (String(), TextRead(format=TextFormat.string)), - (Enum(ColourEnum), TextRead(format=TextFormat.string)), - (Waveform(array_dtype=np.int32), ArrayTrace(axis="x")), + (AttrR(bool), LED()), + (AttrR(int), TextRead()), + (AttrR(float), TextRead()), + (AttrR(str), TextRead(format=TextFormat.string)), + (AttrR(ColourEnum), TextRead(format=TextFormat.string)), + (AttrR(Array1D[np.int32]), ArrayTrace(axis="x")), ], ) -def test_get_attribute_component_r(datatype, widget): +def test_get_attribute_component_r(attribute, widget): gui = EpicsGUI(ControllerAPI()) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(datatype)) == SignalR( + assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) == SignalR( name="Attr", read_pv="DEVICE:Attr", read_widget=widget ) @pytest.mark.parametrize( - "datatype", + "attribute", [ - (Waveform(array_dtype=np.int32, shape=(10, 10))), + AttrR(np.ndarray, array_dtype=np.int32, shape=(10, 10)), ], ) -def test_get_attribute_component_r_signal_none(datatype): +def test_get_attribute_component_r_signal_none(attribute): gui = EpicsGUI(ControllerAPI()) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(datatype)) is None + assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) is None @pytest.mark.parametrize( - "datatype, widget", + "attribute, widget", [ - (Bool(), ToggleButton()), - (Int(), TextWrite()), - (Float(), TextWrite()), - (String(), TextWrite(format=TextFormat.string)), - (Enum(ColourEnum), ComboBox(choices=["RED", "GREEN", "BLUE"])), + (AttrW(bool), ToggleButton()), + (AttrW(int), TextWrite()), + (AttrW(float), TextWrite()), + (AttrW(str), TextWrite(format=TextFormat.string)), + (AttrW(ColourEnum), ComboBox(choices=["RED", "GREEN", "BLUE"])), ], ) -def test_get_attribute_component_w(datatype, widget): +def test_get_attribute_component_w(attribute, widget): gui = EpicsGUI(ControllerAPI()) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrW(datatype)) == SignalW( + assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) == SignalW( name="Attr", write_pv="DEVICE:Attr", write_widget=widget ) @@ -90,16 +90,14 @@ def test_get_attribute_component_none(mocker): mocker.patch.object(gui, "_get_read_widget", return_value=None) mocker.patch.object(gui, "_get_write_widget", return_value=None) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(Int())) is None - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrW(Int())) is None - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrRW(Int())) is None + assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(int)) is None + assert gui._get_attribute_component(["DEVICE"], "Attr", AttrW(int)) is None + assert gui._get_attribute_component(["DEVICE"], "Attr", AttrRW(int)) is None def test_get_write_widget_none(): gui = EpicsGUI(ControllerAPI()) - assert ( - gui._get_write_widget(attribute=AttrR(Waveform(array_dtype=np.int32))) is None - ) + assert gui._get_write_widget(attribute=AttrR(Array1D[np.int32])) is None def test_get_components(controller): @@ -195,11 +193,11 @@ def test_get_command_component(): class _A(Controller): - foo = AttrR(Int()) + foo = AttrR(int) class _B(Controller): - bar = AttrR(Int()) + bar = AttrR(int) def _api_with_id(cls, name): diff --git a/tests/transports/epics/ca/test_initial_value.py b/tests/transports/epics/ca/test_initial_value.py index b78090dd0..9563e7e55 100644 --- a/tests/transports/epics/ca/test_initial_value.py +++ b/tests/transports/epics/ca/test_initial_value.py @@ -7,7 +7,7 @@ import fastcs.transports.epics.ca.ioc as ca_ioc from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D from fastcs.launch import FastCS from fastcs.transports.epics.ca.transport import EpicsCATransport @@ -19,30 +19,32 @@ class InitialEnum(enum.Enum): class InitialValuesController(Controller): - int = AttrRW(Int(), initial_value=4) - float = AttrRW(Float(), initial_value=3.1) - bool = AttrRW(Bool(), initial_value=True) - enum = AttrRW(Enum(InitialEnum), initial_value=InitialEnum.B) - str = AttrRW(String(), initial_value="initial") - waveform = AttrRW( - Waveform(np.int64, shape=(10,)), + int_rw = AttrRW(int, initial_value=4) + float_rw = AttrRW(float, initial_value=3.1) + bool_rw = AttrRW(bool, initial_value=True) + enum_rw = AttrRW(InitialEnum, initial_value=InitialEnum.B) + str_rw = AttrRW(str, initial_value="initial") + waveform_rw = AttrRW( + Array1D[np.int64], initial_value=np.array(range(10), dtype=np.int64), + shape=(10,), ) - int_r = AttrR(Int(), initial_value=5) - float_r = AttrR(Float(), initial_value=4.1) - bool_r = AttrR(Bool(), initial_value=False) - enum_r = AttrR(Enum(InitialEnum), initial_value=InitialEnum.C) - str_r = AttrR(String(), initial_value="initial_r") + int_r = AttrR(int, initial_value=5) + float_r = AttrR(float, initial_value=4.1) + bool_r = AttrR(bool, initial_value=False) + enum_r = AttrR(InitialEnum, initial_value=InitialEnum.C) + str_r = AttrR(str, initial_value="initial_r") waveform_r = AttrR( - Waveform(np.int64, shape=(10,)), + Array1D[np.int64], initial_value=np.array(range(10, 20), dtype=np.int64), + shape=(10,), ) - int_w = AttrW(Int()) - float_w = AttrW(Float()) - bool_w = AttrW(Bool()) - enum_w = AttrW(Enum(InitialEnum)) - str_w = AttrW(String()) - waveform_w = AttrW(Waveform(np.int64, shape=(10,))) + int_w = AttrW(int) + float_w = AttrW(float) + bool_w = AttrW(bool) + enum_w = AttrW(InitialEnum) + str_w = AttrW(str) + waveform_w = AttrW(Array1D[np.int64], shape=(10,)) @pytest.mark.forked @@ -73,27 +75,27 @@ async def test_initial_values_set_in_ca(mocker): for wrapper in record_spy.spy_return_list + record_spy_out.spy_return_list } for name, value in { - "SOFTIOC_INITIAL_DEVICE:Bool": 1, + "SOFTIOC_INITIAL_DEVICE:BoolRw": 1, "SOFTIOC_INITIAL_DEVICE:BoolR": 0, "SOFTIOC_INITIAL_DEVICE:BoolW": 0, - "SOFTIOC_INITIAL_DEVICE:Bool_RBV": 1, - "SOFTIOC_INITIAL_DEVICE:Enum": 1, + "SOFTIOC_INITIAL_DEVICE:BoolRw_RBV": 1, + "SOFTIOC_INITIAL_DEVICE:EnumRw": 1, "SOFTIOC_INITIAL_DEVICE:EnumR": 2, "SOFTIOC_INITIAL_DEVICE:EnumW": 0, - "SOFTIOC_INITIAL_DEVICE:Enum_RBV": 1, - "SOFTIOC_INITIAL_DEVICE:Float": 3.1, + "SOFTIOC_INITIAL_DEVICE:EnumRw_RBV": 1, + "SOFTIOC_INITIAL_DEVICE:FloatRw": 3.1, "SOFTIOC_INITIAL_DEVICE:FloatR": 4.1, "SOFTIOC_INITIAL_DEVICE:FloatW": 0.0, - "SOFTIOC_INITIAL_DEVICE:Float_RBV": 3.1, - "SOFTIOC_INITIAL_DEVICE:Int": 4, + "SOFTIOC_INITIAL_DEVICE:FloatRw_RBV": 3.1, + "SOFTIOC_INITIAL_DEVICE:IntRw": 4, "SOFTIOC_INITIAL_DEVICE:IntR": 5, "SOFTIOC_INITIAL_DEVICE:IntW": 0, - "SOFTIOC_INITIAL_DEVICE:Int_RBV": 4, - "SOFTIOC_INITIAL_DEVICE:Str": "initial", + "SOFTIOC_INITIAL_DEVICE:IntRw_RBV": 4, + "SOFTIOC_INITIAL_DEVICE:StrRw": "initial", "SOFTIOC_INITIAL_DEVICE:StrR": "initial_r", "SOFTIOC_INITIAL_DEVICE:StrW": "", - "SOFTIOC_INITIAL_DEVICE:Str_RBV": "initial", - "SOFTIOC_INITIAL_DEVICE:Waveform": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + "SOFTIOC_INITIAL_DEVICE:StrRw_RBV": "initial", + "SOFTIOC_INITIAL_DEVICE:WaveformRw": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], "SOFTIOC_INITIAL_DEVICE:WaveformR": [ 10, 11, @@ -107,7 +109,7 @@ async def test_initial_values_set_in_ca(mocker): 19, ], "SOFTIOC_INITIAL_DEVICE:WaveformW": 10 * [0], - "SOFTIOC_INITIAL_DEVICE:Waveform_RBV": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + "SOFTIOC_INITIAL_DEVICE:WaveformRw_RBV": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], }.items(): assert np.array_equal(value, initial_values[name]) except Exception as e: diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py index 8b7c12205..a29958cdd 100644 --- a/tests/transports/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -8,14 +8,13 @@ from softioc import softioc from tests.assertable_controller import ( AssertableControllerAPI, - MyTestAttributeIORef, MyTestController, ) from tests.util import ColourEnum from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerAPI -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D, Limits, Meta, NumericLimits from fastcs.exceptions import FastCSError from fastcs.methods import Command from fastcs.transports.epics.ca import EpicsCATransport @@ -53,8 +52,8 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): ) record = make_record.return_value - attribute = AttrR(Int()) - attribute.add_on_update_callback = mocker.MagicMock() + attribute = AttrR(int) + attribute.add_readback_callback = mocker.MagicMock() _create_and_link_read_pv("PREFIX", "PV", "attr", None, attribute) @@ -62,8 +61,8 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "r") # Extract the callback generated and set in the function and call it - attribute.add_on_update_callback.assert_called_once_with(mocker.ANY) - record_set_callback = attribute.add_on_update_callback.call_args[0][0] + attribute.add_readback_callback.assert_called_once_with(mocker.ANY) + record_set_callback = attribute.add_readback_callback.call_args[0][0] await record_set_callback(1) record.set.assert_called_once_with(1) @@ -152,17 +151,17 @@ async def test_ioc_raises_if_duplicate_aliases_provided(mocker: MockerFixture): "attribute,record_type,kwargs", ( ( - AttrR(String()), + AttrR(str), "longStringIn", {"length": 257, "DESC": None, "initial_value": ""}, ), ( - AttrR(String(length=10)), + AttrR(str, length=10), "longStringIn", {"length": 11, "DESC": None, "initial_value": ""}, ), ( - AttrR(Enum(ColourEnum)), + AttrR(ColourEnum), "mbbIn", { "ZRST": "RED", @@ -174,18 +173,16 @@ async def test_ioc_raises_if_duplicate_aliases_provided(mocker: MockerFixture): ), ( AttrR( - Enum( - enum.IntEnum( - "ONOFF_STATES", - {"DISABLED": 0, "ENABLED": 1}, - ) + enum.IntEnum( + "ONOFF_STATES", + {"DISABLED": 0, "ENABLED": 1}, ) ), "mbbIn", {"ZRST": "DISABLED", "ONST": "ENABLED", "DESC": None, "initial_value": 0}, ), ( - AttrR(Waveform(np.int32, (10,))), + AttrR(Array1D[np.int32], shape=(10,)), "WaveformIn", { "DESC": None, @@ -213,11 +210,17 @@ def test_make_input_record( ) +def _attribute_of_unsupported_datatype(mocker: MockerFixture): + attribute = mocker.MagicMock() + attribute.dtype = object + return attribute + + def test_make_record_raises(mocker: MockerFixture): mocker.patch("fastcs.transports.epics.ca.util.cast_to_epics_type") - # Pass a mock as attribute to provoke the fallback case matching on datatype + # An attribute of a datatype EPICS cannot serve, to provoke the fallback with pytest.raises(FastCSError): - _make_in_record("PV", mocker.MagicMock()) + _make_in_record("PV", _attribute_of_unsupported_datatype(mocker)) @pytest.mark.asyncio @@ -228,27 +231,32 @@ async def test_create_and_link_write_pv(mocker: MockerFixture): ) record = make_record.return_value - attribute = AttrW(Int()) - attribute.put = mocker.AsyncMock() - attribute.add_sync_setpoint_callback = mocker.MagicMock() + attribute = AttrRW(int) + attribute.set = mocker.AsyncMock() + attribute.add_setpoint_callback = mocker.MagicMock() _create_and_link_write_pv("PREFIX", "PV", "attr", None, attribute) make_record.assert_called_once_with("PREFIX:PV", attribute, on_update=mocker.ANY) add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "w") - # Extract the write update callback generated and set in the function and call it - attribute.add_sync_setpoint_callback.assert_called_once_with(mocker.ANY) - sync_setpoint_callback = attribute.add_sync_setpoint_callback.call_args[0][0] - await sync_setpoint_callback(1) + # Extract the setpoint callback generated and set in the function + attribute.add_setpoint_callback.assert_called_once_with(mocker.ANY) + set_setpoint_callback = attribute.add_setpoint_callback.call_args[0][0] + await set_setpoint_callback(1) record.set.assert_called_once_with(1, process=False) + # Unlike the old one-shot seeding, every setpoint change is mirrored. + record.set.reset_mock() + await set_setpoint_callback(2) + record.set.assert_called_once_with(2, process=False) + # Extract the on update callback generated and set in the function and call it on_update_callback = make_record.call_args[1]["on_update"] await on_update_callback(1) - attribute.put.assert_called_once_with(1) + attribute.set.assert_called_once_with(1) class LongEnum(enum.Enum): @@ -275,7 +283,7 @@ class LongEnum(enum.Enum): "attribute,record_type,kwargs", ( ( - AttrW(Enum(enum.IntEnum("ONOFF_STATES", {"DISABLED": 0, "ENABLED": 1}))), + AttrW(enum.IntEnum("ONOFF_STATES", {"DISABLED": 0, "ENABLED": 1})), "mbbOut", { "ZRST": "DISABLED", @@ -285,12 +293,12 @@ class LongEnum(enum.Enum): }, ), ( - AttrW(String()), + AttrW(str), "longStringOut", {"length": 257, "DESC": None, "initial_value": ""}, ), ( - AttrW(String(length=10)), + AttrW(str, length=10), "longStringOut", {"length": 11, "DESC": None, "initial_value": ""}, ), @@ -319,7 +327,7 @@ def test_make_output_record( def test_long_enum_validator(mocker: MockerFixture): builder = mocker.patch("fastcs.transports.epics.ca.util.builder") update = mocker.MagicMock() - attribute = AttrRW(Enum(LongEnum)) + attribute = AttrRW(LongEnum) pv = "PV" record = _make_out_record(pv, attribute, on_update=update) validator = builder.longStringOut.call_args.kwargs["validate"] @@ -329,7 +337,7 @@ def test_long_enum_validator(mocker: MockerFixture): def test_long_enum_in_creation(mocker: MockerFixture): builder = mocker.patch("fastcs.transports.epics.ca.util.builder") - attribute = AttrR(Enum(LongEnum)) + attribute = AttrR(LongEnum) pv = "PV" _make_in_record(pv, attribute) assert builder.longStringIn.call_args.kwargs["initial_value"] == "THIS" @@ -337,20 +345,24 @@ def test_long_enum_in_creation(mocker: MockerFixture): def test_get_output_record_raises(mocker: MockerFixture): mocker.patch("fastcs.transports.epics.ca.util.cast_to_epics_type") - # Pass a mock as attribute to provoke the fallback case matching on datatype + # An attribute of a datatype EPICS cannot serve, to provoke the fallback with pytest.raises(FastCSError): - _make_out_record("PV", mocker.MagicMock(), on_update=mocker.MagicMock()) + _make_out_record( + "PV", + _attribute_of_unsupported_datatype(mocker), + on_update=mocker.MagicMock(), + ) class EpicsController(MyTestController): - read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) - read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) - read_write_float = AttrRW(Float()) - read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) - read_string = AttrRW(String()) - enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) - one_d_waveform = AttrRW(Waveform(np.int32, (10,))) + read_int = AttrR(int) + read_write_int = AttrRW(int) + read_write_float = AttrRW(float) + read_bool = AttrR(bool) + write_bool = AttrW(bool) + read_string = AttrRW(str) + enum = AttrRW(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2})) + one_d_waveform = AttrRW(Array1D[np.int32], shape=(10,)) @pytest.fixture() @@ -573,9 +585,9 @@ async def do_nothing(): ... class ControllerLongNames(Controller): - attr_r_with_reallyreallyreallyreallyreallyreallyreally_long_name = AttrR(Int()) - attr_rw_with_a_reallyreally_long_name_that_is_too_long_for_rbv = AttrRW(Int()) - attr_rw_short_name = AttrRW(Int()) + attr_r_with_reallyreallyreallyreallyreallyreallyreally_long_name = AttrR(int) + attr_rw_with_a_reallyreally_long_name_that_is_too_long_for_rbv = AttrRW(int) + attr_rw_short_name = AttrRW(int) command_with_reallyreallyreallyreallyreallyreallyreally_long_name = Command( do_nothing ) @@ -671,10 +683,10 @@ def test_non_1d_waveforms_discarded(mocker: MockerFixture): api = ControllerAPI( path=[DEVICE], attributes={ - "waveform_0d": AttrR(Waveform(np.int32, shape=())), - "waveform_1d": AttrR(Waveform(np.int32, shape=(10,))), - "waveform_2d": AttrR(Waveform(np.int32, shape=(10, 2))), - "waveform_3d": AttrR(Waveform(np.int32, shape=(10, 2, 3))), + "waveform_0d": AttrR(Array1D[np.int32], shape=()), + "waveform_1d": AttrR(Array1D[np.int32], shape=(10,)), + "waveform_2d": AttrR(Array1D[np.int32], shape=(10, 2)), + "waveform_3d": AttrR(Array1D[np.int32], shape=(10, 2, 3)), }, ) @@ -688,12 +700,12 @@ def test_non_1d_waveforms_discarded(mocker: MockerFixture): ) -def test_update_datatype(mocker: MockerFixture): +def test_update_meta(mocker: MockerFixture): builder = mocker.patch("fastcs.transports.epics.ca.util.builder") pv_name = f"{DEVICE}:Attr" - attr_r = AttrR(Int()) + attr_r = AttrR(int) record_r = _make_in_record(pv_name, attr_r) builder.longIn.assert_called_once_with( @@ -705,17 +717,17 @@ def test_update_datatype(mocker: MockerFixture): initial_value=0, ) record_r.set_field.assert_not_called() - attr_r.update_datatype(Int(units="m", min_alarm=-3)) + attr_r.update_meta(Meta(units="m", limits=NumericLimits(display=Limits(low=-3)))) record_r.set_field.assert_any_call("EGU", "m") record_r.set_field.assert_any_call("LOPR", -3) with pytest.raises( - ValueError, - match="Attribute datatype must be of type ", + TypeError, + match="'precision' is not valid metadata for int", ): - attr_r.update_datatype(String()) # type: ignore + attr_r.update_meta(Meta(precision=3)) - attr_w = AttrW(Int()) + attr_w = AttrW(int) record_w = _make_out_record(pv_name, attr_w, on_update=mocker.ANY) builder.longOut.assert_called_once_with( @@ -732,16 +744,21 @@ def test_update_datatype(mocker: MockerFixture): blocking=True, ) record_w.set_field.assert_not_called() - attr_w.update_datatype(Int(units="m", min_alarm=-1, min=-3)) + attr_w.update_meta( + Meta( + units="m", + limits=NumericLimits(display=Limits(low=-1), control=Limits(low=-3)), + ) + ) record_w.set_field.assert_any_call("EGU", "m") record_w.set_field.assert_any_call("LOPR", -1) record_w.set_field.assert_any_call("DRVL", -3) with pytest.raises( - ValueError, - match="Attribute datatype must be of type ", + TypeError, + match="'precision' is not valid metadata for int", ): - attr_w.update_datatype(String()) # type: ignore + attr_w.update_meta(Meta(precision=3)) def test_ca_context_contains_softioc_commands(mocker: MockerFixture): diff --git a/tests/transports/epics/ca/test_softioc_system.py b/tests/transports/epics/ca/test_softioc_system.py index 7544f5308..fd1b1ddd0 100644 --- a/tests/transports/epics/ca/test_softioc_system.py +++ b/tests/transports/epics/ca/test_softioc_system.py @@ -16,6 +16,7 @@ def test_ioc(softioc_subprocess: tuple[str, Queue]): assert parent_pvi["value"] == { "a": {"r": f"{pv_prefix}:A"}, "b": {"r": f"{pv_prefix}:B_RBV", "w": f"{pv_prefix}:B"}, + "clamped": {"r": f"{pv_prefix}:Clamped_RBV", "w": f"{pv_prefix}:Clamped"}, "childvector": {"d": f"{pv_prefix}:ChildVector:PVI"}, } @@ -45,6 +46,10 @@ def test_ioc(softioc_subprocess: tuple[str, Queue]): "d": {"x": f"{pv_prefix}:ChildVector:0:D"}, } + initial_value = ctxt.get(f"{pv_prefix}:Clamped_RBV") + assert initial_value # Clamped initial value is truthy + assert ctxt.get(f"{pv_prefix}:Clamped") == initial_value # Setpoint is synced + # Assert alias. Aliases do not show up in PVI structure assert ctxt.get(f"{pv_prefix}:B") == ctxt.get(f"{pv_prefix}:AliasB") == 0 ctxt.put(f"{pv_prefix}:B", 10, wait=True) diff --git a/tests/transports/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py index 947ade79d..56fe9b897 100644 --- a/tests/transports/epics/pva/test_p4p.py +++ b/tests/transports/epics/pva/test_p4p.py @@ -12,10 +12,11 @@ from p4p.client.asyncio import Context from p4p.client.thread import Context as ThreadContext from p4p.nt import NTTable +from pytest_mock import MockerFixture from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Bool, Enum, Float, Int, String, Table, Waveform +from fastcs.datatypes import Array1D, Limits, NumericLimits, Table from fastcs.launch import FastCS from fastcs.methods import command from fastcs.transports.epics.pva.transport import EpicsPVATransport @@ -62,6 +63,7 @@ async def test_ioc(p4p_subprocess: tuple[str, Queue]): assert child_pvi["display"] == {"description": "some sub controller"} assert child_pvi["value"] == { "c": {"w": f"{pv_prefix}:Child:1:C"}, + "clamped": {"rw": f"{pv_prefix}:Child:1:Clamped"}, "d": {"x": f"{pv_prefix}:Child:1:D"}, "e": {"r": f"{pv_prefix}:Child:1:E"}, "f": {"rw": f"{pv_prefix}:Child:1:F"}, @@ -71,6 +73,12 @@ async def test_ioc(p4p_subprocess: tuple[str, Queue]): "j": {"r": f"{pv_prefix}:Child:1:J"}, } + initial_value = await ctxt.get(f"{pv_prefix}:Child:1:Clamped_RBV") + assert initial_value # Clamped initial value is truthy + assert ( + await ctxt.get(f"{pv_prefix}:Child:1:Clamped") == initial_value + ) # Setpoint is synced + @pytest.mark.asyncio async def test_scan_method(p4p_subprocess: tuple[str, Queue]): @@ -216,8 +224,17 @@ def make_fastcs(pv_prefix: str, controller: Controller) -> FastCS: def test_read_signal_set(): class SomeController(Controller): - a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) - b: AttrR = AttrR(Float(min=-1, min_alarm=-0.5, prec=2)) + a: AttrRW = AttrRW( + int, + limits=NumericLimits( + control=Limits(high=400_000), alarm=Limits(high=40_000) + ), + ) + b: AttrR = AttrR( + float, + limits=NumericLimits(control=Limits(low=-1), alarm=Limits(low=-0.5)), + precision=2, + ) controller = SomeController() pv_prefix = str(uuid4()) @@ -257,20 +274,29 @@ async def _wait_and_set_attr_r(): def test_pvi_grouping(): class ChildChildController(Controller): - attr_e: AttrRW = AttrRW(Int()) - attr_f: AttrR = AttrR(String()) + attr_e: AttrRW = AttrRW(int) + attr_f: AttrR = AttrR(str) class ChildController(Controller): - attr_c: AttrW = AttrW(Bool(), description="Some bool") - attr_d: AttrW = AttrW(String()) + attr_c: AttrW = AttrW(bool, description="Some bool") + attr_d: AttrW = AttrW(str) class SomeController(Controller): description = "some controller" - attr_1: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) - attr_1: AttrRW = AttrRW(Float(min=-1, min_alarm=-0.5, prec=2)) - another_attr_0: AttrRW = AttrRW(Int()) - another_attr_1000: AttrRW = AttrRW(Int()) - a_third_attr: AttrW = AttrW(Int()) + attr_1: AttrRW = AttrRW( + int, + limits=NumericLimits( + control=Limits(high=400_000), alarm=Limits(high=40_000) + ), + ) + attr_1: AttrRW = AttrRW( + float, + limits=NumericLimits(control=Limits(low=-1), alarm=Limits(low=-0.5)), + precision=2, + ) + another_attr_0: AttrRW = AttrRW(int) + another_attr_1000: AttrRW = AttrRW(int) + a_third_attr: AttrW = AttrW(int) controller = SomeController() @@ -407,9 +433,9 @@ class AnEnum(enum.Enum): C = 3 class SomeController(Controller): - some_waveform: AttrRW = AttrRW(Waveform(np.int64, shape=(10, 10))) - some_table: AttrRW = AttrRW(Table(table_columns)) - some_enum: AttrRW = AttrRW(Enum(AnEnum)) + some_waveform: AttrRW = AttrRW(Array1D[np.int64], shape=(10, 10)) + some_table: AttrRW = AttrRW(Table, structured_dtype=table_columns) + some_enum: AttrRW = AttrRW(AnEnum) controller = SomeController() pv_prefix = str(uuid4()) @@ -517,12 +543,7 @@ async def _wait_and_put_pvs(): ] for expected_enum, actual_enum in zip(expected_enum_gets, enum_values, strict=True): - assert ( - expected_enum - == controller.some_enum.datatype.members[ # type: ignore - actual_enum.todict()["value"]["index"] - ] - ) + assert expected_enum == list(AnEnum)[actual_enum.todict()["value"]["index"]] def test_command_method_put_twice(caplog): @@ -654,3 +675,55 @@ async def put_pvs(): assert ( pytest.approx((end - start).total_seconds(), abs=0.1) == expected_duration ) + + +@pytest.mark.asyncio +async def test_setpoint_seeded_by_initial_poll_reaches_transport( + mocker: MockerFixture, +): + """The PVs must exist by the end of ``connect()``, not ``serve()``. + + An ``AttrRW`` seeds its setpoint from its first readback (ADR 0020), and that + readback comes from the initial poll - which ``FastCS.serve`` runs *before* it + gathers the transports' ``serve()`` coroutines. A PV built in ``serve()`` would + miss the seed and keep serving the datatype default, so the setpoint PV read + ``0`` while ``attribute.setpoint`` read the seeded value. + """ + + class SeedController(Controller): + def __init__(self): + super().__init__() + self.a = AttrRW(int, getter=self.get_a) + + async def get_a(self) -> int: + return 10 + + controller = SeedController() + controller.set_path([str(uuid4())]) + await controller.initialise() + controller.post_initialise() + controller_api, _, initial_coros = controller.create_api_and_tasks() + + attribute = controller_api.attributes["a"] + assert isinstance(attribute, AttrRW) + published: list[int] = [] + register_callback = attribute.add_setpoint_callback + + def record_setpoints(callback): + async def wrapper(value): + published.append(value) + await callback(value) + + register_callback(wrapper) + + mocker.patch.object(attribute, "add_setpoint_callback", record_setpoints) + + transport = EpicsPVATransport() + transport.connect(controller_apis=[controller_api], loop=asyncio.get_running_loop()) + + # Nothing has awaited transport.serve() at this point - as in FastCS.serve() + for coro in initial_coros: + await coro() + + assert attribute.setpoint == 10 + assert published == [10] diff --git a/tests/transports/epics/pva/test_pva_gui.py b/tests/transports/epics/pva/test_pva_gui.py index 4a753608e..6870110b0 100644 --- a/tests/transports/epics/pva/test_pva_gui.py +++ b/tests/transports/epics/pva/test_pva_gui.py @@ -4,6 +4,7 @@ LED, ButtonPanel, CheckBox, + ImageColorMap, ImageRead, SignalR, SignalW, @@ -17,22 +18,24 @@ from fastcs.attributes import AttrR, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import Table, Waveform -from fastcs.transports.epics.gui import EpicsGUI +from fastcs.datatypes import Table from fastcs.transports.epics.pva.gui import PvaEpicsGUI @pytest.mark.parametrize( - "datatype, widget", + "attribute, widget", [ - (Waveform(array_dtype=np.int32), ImageRead()), + ( + AttrR(np.ndarray, array_dtype=np.int32, shape=(10, 20)), + ImageRead(height=10, width=20, color_map=ImageColorMap.GRAY), + ), ], ) -def test_pva_get_attribute_component_r(datatype, widget): - gui = EpicsGUI(ControllerAPI()) +def test_pva_get_attribute_component_r(attribute, widget): + gui = PvaEpicsGUI(ControllerAPI()) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(datatype)) == SignalR( - name="Attr", read_pv="DEVICE:Attr", read_widget=widget + assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) == SignalR( + name="Attr", read_pv="pva://DEVICE:Attr", read_widget=widget ) @@ -51,13 +54,12 @@ def test_get_attribute_component_table_write(): ["DEVICE"], "Table", AttrW( - Table( - structured_dtype=[ - ("FIELD1", np.uint32), - ("FIELD2", np.bool), - ("FIELD3", np.dtype("S1000")), - ] - ) + Table, + structured_dtype=[ + ("FIELD1", np.uint32), + ("FIELD2", np.bool), + ("FIELD3", np.dtype("S1000")), + ], ), ) @@ -77,13 +79,12 @@ def test_get_attribute_component_table_read(): ["DEVICE"], "Table", AttrR( - Table( - structured_dtype=[ - ("FIELD1", np.uint32), - ("FIELD2", np.bool), - ("FIELD3", np.dtype("S1000")), - ] - ) + Table, + structured_dtype=[ + ("FIELD1", np.uint32), + ("FIELD2", np.bool), + ("FIELD3", np.dtype("S1000")), + ], ), ) diff --git a/tests/transports/epics/test_emission.py b/tests/transports/epics/test_emission.py index b4e223650..e83d0ac3f 100644 --- a/tests/transports/epics/test_emission.py +++ b/tests/transports/epics/test_emission.py @@ -6,7 +6,6 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import Int from fastcs.transports.epics.emission import ( DOCS_EXT, INDEX_STEM, @@ -24,11 +23,11 @@ class _Alpha(Controller): - foo = AttrR(Int()) + foo = AttrR(int) class _Beta(Controller): - bar = AttrR(Int()) + bar = AttrR(int) def _api_with_id(controller_class: type[Controller], name: str): diff --git a/tests/transports/graphQL/test_graphql.py b/tests/transports/graphQL/test_graphql.py index 46d2fc8a9..ae618141c 100644 --- a/tests/transports/graphQL/test_graphql.py +++ b/tests/transports/graphQL/test_graphql.py @@ -8,22 +8,20 @@ from pytest_mock import MockerFixture from tests.assertable_controller import ( AssertableControllerAPI, - MyTestAttributeIORef, MyTestController, ) from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.datatypes import Bool, Float, Int, String from fastcs.transports.graphql.transport import GraphQLTransport class GraphQLController(MyTestController): - read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) - read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) - read_write_float = AttrRW(Float()) - read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) - read_string = AttrRW(String()) + read_int = AttrR(int) + read_write_int = AttrRW(int) + read_write_float = AttrRW(float) + read_bool = AttrR(bool) + write_bool = AttrW(bool) + read_string = AttrRW(str) _GQL_ID = "device" diff --git a/tests/transports/rest/test_rest.py b/tests/transports/rest/test_rest.py index 80af6698b..8bb7d97cc 100644 --- a/tests/transports/rest/test_rest.py +++ b/tests/transports/rest/test_rest.py @@ -9,20 +9,20 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D from fastcs.transports.rest.transport import RestTransport class RestController(MyTestController): - read_int = AttrR(Int()) - read_write_int = AttrRW(Int()) - read_write_float = AttrRW(Float()) - read_bool = AttrR(Bool()) - write_bool = AttrW(Bool()) - read_string = AttrRW(String()) - enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) - one_d_waveform = AttrRW(Waveform(np.int32, (10,))) - two_d_waveform = AttrRW(Waveform(np.int32, (10, 10))) + read_int = AttrR(int) + read_write_int = AttrRW(int) + read_write_float = AttrRW(float) + read_bool = AttrR(bool) + write_bool = AttrW(bool) + read_string = AttrRW(str) + enum = AttrRW(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2})) + one_d_waveform = AttrRW(Array1D[np.int32], shape=(10,)) + two_d_waveform = AttrRW(Array1D[np.int32], shape=(10, 10)) @pytest.fixture(scope="class") @@ -97,9 +97,9 @@ def test_enum( ): enum_attr = rest_controller_api.attributes["enum"] assert isinstance(enum_attr, AttrRW) - enum_cls = enum_attr.datatype.dtype - assert isinstance(enum_attr.get(), enum_cls) - assert enum_attr.get() == enum_cls(0) + enum_cls = enum_attr.dtype + assert isinstance(enum_attr.readback, enum_cls) + assert enum_attr.readback == enum_cls(0) expect = 0 with rest_controller_api.assert_read_here(["enum"]): response = test_client.get("/enum") @@ -109,8 +109,8 @@ def test_enum( with rest_controller_api.assert_write_here(["enum"]): response = test_client.put("/enum", json={"value": new}) assert test_client.get("/enum").json()["value"] == new - assert isinstance(enum_attr.get(), enum_cls) - assert enum_attr.get() == enum_cls(2) + assert isinstance(enum_attr.readback, enum_cls) + assert enum_attr.readback == enum_cls(2) def test_1d_waveform( self, rest_controller_api: AssertableControllerAPI, test_client: TestClient @@ -118,8 +118,8 @@ def test_1d_waveform( attribute = rest_controller_api.attributes["one_d_waveform"] expect = np.zeros((10,), dtype=np.int32) assert isinstance(attribute, AttrRW) - assert np.array_equal(attribute.get(), expect) - assert isinstance(attribute.get(), np.ndarray) + assert np.array_equal(attribute.readback, expect) + assert isinstance(attribute.readback, np.ndarray) with rest_controller_api.assert_read_here(["one_d_waveform"]): response = test_client.get("one-d-waveform") @@ -131,8 +131,8 @@ def test_1d_waveform( result = test_client.get("/one-d-waveform") assert np.array_equal(result.json()["value"], new) - assert np.array_equal(attribute.get(), new) - assert isinstance(attribute.get(), np.ndarray) + assert np.array_equal(attribute.readback, new) + assert isinstance(attribute.readback, np.ndarray) def test_2d_waveform( self, rest_controller_api: AssertableControllerAPI, test_client: TestClient @@ -140,8 +140,8 @@ def test_2d_waveform( attribute = rest_controller_api.attributes["two_d_waveform"] assert isinstance(attribute, AttrRW) expect = np.zeros((10, 10), dtype=np.int32) - assert np.array_equal(attribute.get(), expect) - assert isinstance(attribute.get(), np.ndarray) + assert np.array_equal(attribute.readback, expect) + assert isinstance(attribute.readback, np.ndarray) with rest_controller_api.assert_read_here(["two_d_waveform"]): result = test_client.get("/two-d-waveform") @@ -152,8 +152,8 @@ def test_2d_waveform( result = test_client.get("/two-d-waveform") assert np.array_equal(result.json()["value"], new) - assert np.array_equal(attribute.get(), new) - assert isinstance(attribute.get(), np.ndarray) + assert np.array_equal(attribute.readback, new) + assert isinstance(attribute.readback, np.ndarray) def test_go( self, rest_controller_api: AssertableControllerAPI, test_client: TestClient diff --git a/tests/transports/tango/test_dsr.py b/tests/transports/tango/test_dsr.py index 61a8f73ef..abbaa439a 100644 --- a/tests/transports/tango/test_dsr.py +++ b/tests/transports/tango/test_dsr.py @@ -12,7 +12,7 @@ ) from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D from fastcs.transports.tango.transport import TangoTransport @@ -30,15 +30,15 @@ def mock_run_threadsafe_blocking(module_mocker: MockerFixture): class TangoController(MyTestController): - read_int = AttrR(Int()) - read_write_int = AttrRW(Int()) - read_write_float = AttrRW(Float()) - read_bool = AttrR(Bool()) - write_bool = AttrW(Bool()) - read_string = AttrRW(String()) - enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) - one_d_waveform = AttrRW(Waveform(np.int32, (10,))) - two_d_waveform = AttrRW(Waveform(np.int32, (10, 10))) + read_int = AttrR(int) + read_write_int = AttrRW(int) + read_write_float = AttrRW(float) + read_bool = AttrR(bool) + write_bool = AttrW(bool) + read_string = AttrRW(str) + enum = AttrRW(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2})) + one_d_waveform = AttrRW(Array1D[np.int32], shape=(10,)) + two_d_waveform = AttrRW(Array1D[np.int32], shape=(10, 10)) @pytest.fixture(scope="class") @@ -148,9 +148,9 @@ def test_write_bool( def test_enum(self, tango_controller_api: AssertableControllerAPI, tango_context): enum_attr = tango_controller_api.attributes["enum"] assert isinstance(enum_attr, AttrRW) - enum_cls = enum_attr.datatype.dtype - assert isinstance(enum_attr.get(), enum_cls) - assert enum_attr.get() == enum_cls(0) + enum_cls = enum_attr.dtype + assert isinstance(enum_attr.readback, enum_cls) + assert enum_attr.readback == enum_cls(0) expect = 0 with tango_controller_api.assert_read_here(["enum"]): result = tango_context.read_attribute("Enum").value @@ -159,8 +159,8 @@ def test_enum(self, tango_controller_api: AssertableControllerAPI, tango_context with tango_controller_api.assert_write_here(["enum"]): tango_context.write_attribute("Enum", new) assert tango_context.read_attribute("Enum").value == new - assert isinstance(enum_attr.get(), enum_cls) - assert enum_attr.get() == enum_cls(1) + assert isinstance(enum_attr.readback, enum_cls) + assert enum_attr.readback == enum_cls(1) def test_1d_waveform( self, tango_controller_api: AssertableControllerAPI, tango_context