From 1e9b0e4c26f506c497c022e13588846d6e090b26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 22:36:01 +0000 Subject: [PATCH 1/2] attributes: getter/setter IO rework, remove AttributeIORef/AttributeIO Per-attribute IO moves from a shared, ref-dispatched AttributeIO/ AttributeIORef pair onto plain getter/setter callables passed straight to AttrR/AttrW/AttrRW. Datatype is now optional on the constructors when it can be inferred from the getter/setter annotation. Runtime surface rename: get() -> .readback / .setpoint properties, no-arg update() -> poll() (does the getter read + caches + returns), update(value) stays as a pure cache-push (now also accepting Update[T]), put() -> set() (caches .setpoint, runs the setter, a non-None return updates .readback - the replacement for the old sync_setpoint-callback mechanism). Scheduling in Controller.create_api_and_tasks now polls getter-bearing attrs directly instead of going through an IO update-callback indirection. Removed: AttributeIO, AttributeIORef, ios=, _connect_attribute_ios, _validate_io, the second Attribute/AttrR/AttrW/AttrRW TypeVar. Migrates the demo composition example and all docs snippets that used the old io_ref= wiring. Deliberately out of scope for this PR (left for a follow-up): the DataType family / *Meta TypedDict replacement and the associated precision/Limits naming pass - the issue's own sizing note allows splitting the getter/setter half from the DataType-removal half. Closes #392 --- docs/snippets/dynamic.py | 92 ++-- docs/snippets/static07.py | 34 +- docs/snippets/static08.py | 48 +- docs/snippets/static09.py | 67 +-- docs/snippets/static10.py | 89 ++-- docs/snippets/static11.py | 102 +++-- docs/snippets/static12.py | 114 +++-- docs/snippets/static13.py | 116 +++-- docs/snippets/static14.py | 117 +++-- docs/snippets/static15.py | 120 +++-- src/fastcs/attributes/__init__.py | 7 +- src/fastcs/attributes/_infer_datatype.py | 52 +++ src/fastcs/attributes/attr_r.py | 102 +++-- src/fastcs/attributes/attr_rw.py | 81 +++- src/fastcs/attributes/attr_w.py | 118 +++-- src/fastcs/attributes/attribute.py | 16 +- src/fastcs/attributes/attribute_io.py | 60 --- src/fastcs/attributes/attribute_io_ref.py | 26 -- src/fastcs/attributes/update.py | 19 + src/fastcs/controllers/base_controller.py | 44 +- src/fastcs/controllers/controller.py | 25 +- src/fastcs/controllers/controller_vector.py | 6 +- src/fastcs/demo/controllers.py | 129 +++--- src/fastcs/transports/epics/ca/ioc.py | 15 +- src/fastcs/transports/epics/ca/util.py | 4 +- .../transports/epics/pva/_pv_handlers.py | 17 +- src/fastcs/transports/graphql/graphql.py | 4 +- src/fastcs/transports/rest/rest.py | 4 +- src/fastcs/transports/tango/dsr.py | 4 +- tests/assertable_controller.py | 88 ++-- tests/conftest.py | 8 +- tests/example_p4p_ioc.py | 50 +-- tests/test_attribute_logging.py | 2 +- tests/test_attributes.py | 414 ++++++++---------- tests/test_control_system.py | 51 +-- tests/transports/epics/ca/test_softioc.py | 28 +- tests/transports/graphQL/test_graphql.py | 7 +- tests/transports/rest/test_rest.py | 24 +- tests/transports/tango/test_dsr.py | 8 +- 39 files changed, 1217 insertions(+), 1095 deletions(-) create mode 100644 src/fastcs/attributes/_infer_datatype.py delete mode 100644 src/fastcs/attributes/attribute_io.py delete mode 100644 src/fastcs/attributes/attribute_io_ref.py create mode 100644 src/fastcs/attributes/update.py diff --git a/docs/snippets/dynamic.py b/docs/snippets/dynamic.py index 7dde6dfe5..9a5cc4f55 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.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") @@ -39,7 +47,9 @@ def fastcs_datatype(self) -> DataType: return String() -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.dtype): + 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.dtype): + 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/static07.py b/docs/snippets/static07.py index cac5549d3..7848c8b44 100644 --- a/docs/snippets/static07.py +++ b/docs/snippets/static07.py @@ -1,8 +1,6 @@ -from dataclasses import dataclass from pathlib import Path -from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.attributes import AttrR from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import String @@ -10,35 +8,19 @@ 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(String(), getter=self._get_device_id, poll_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..8ef146c74 100644 --- a/docs/snippets/static08.py +++ b/docs/snippets/static08.py @@ -1,8 +1,7 @@ -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 from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Float, String @@ -10,41 +9,40 @@ 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(String(), getter=self._get_device_id, poll_period=0.2) + self.power = AttrR(Float(), getter=self._get_power, poll_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..3aa81a424 100644 --- a/docs/snippets/static09.py +++ b/docs/snippets/static09.py @@ -1,8 +1,7 @@ -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 from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Float, String @@ -10,48 +9,52 @@ 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(String(), getter=self._get_device_id, poll_period=0.2) + self.power = AttrR(Float(), getter=self._get_power, poll_period=0.2) + self.ramp_rate = AttrRW( + Float(), + getter=self._get_ramp_rate, + setter=self._set_ramp_rate, + poll_period=0.2, + ) + + 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..e77e2f686 100644 --- a/docs/snippets/static10.py +++ b/docs/snippets/static10.py @@ -1,8 +1,7 @@ -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 from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Float, Int, String @@ -10,60 +9,66 @@ 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=self._get_start, setter=self._set_start, poll_period=0.2 + ) + self.end = AttrRW( + Int(), getter=self._get_end, setter=self._set_end, poll_period=0.2 ) + 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(String(), getter=self._get_device_id, poll_period=0.2) + self.power = AttrR(Float(), getter=self._get_power, poll_period=0.2) + self.ramp_rate = AttrRW( + Float(), + getter=self._get_ramp_rate, + setter=self._set_ramp_rate, + poll_period=0.2, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -71,6 +76,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..66fa67659 100644 --- a/docs/snippets/static11.py +++ b/docs/snippets/static11.py @@ -1,9 +1,8 @@ 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 from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String @@ -11,38 +10,23 @@ 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 +34,59 @@ 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=self._get_start, setter=self._set_start, poll_period=0.2 + ) + self.end = AttrRW( + Int(), getter=self._get_end, setter=self._set_end, poll_period=0.2 + ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=self._get_enabled, + setter=self._set_enabled, + poll_period=0.2, ) + 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(String(), getter=self._get_device_id, poll_period=0.2) + self.power = AttrR(Float(), getter=self._get_power, poll_period=0.2) + self.ramp_rate = AttrRW( + Float(), + getter=self._get_ramp_rate, + setter=self._set_ramp_rate, + poll_period=0.2, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -78,6 +94,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..2e4ece1c4 100644 --- a/docs/snippets/static12.py +++ b/docs/snippets/static12.py @@ -1,10 +1,9 @@ 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 from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String @@ -13,38 +12,23 @@ 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 +36,68 @@ 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=self._get_start, setter=self._set_start, poll_period=0.2 + ) + self.end = AttrRW( + Int(), getter=self._get_end, setter=self._set_end, poll_period=0.2 ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=self._get_enabled, + setter=self._set_enabled, + poll_period=0.2, + ) + self.target = AttrR(Float(), getter=self._get_target, poll_period=0.2) + self.actual = AttrR(Float(), getter=self._get_actual, poll_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(String(), getter=self._get_device_id, poll_period=0.2) + self.power = AttrR(Float(), getter=self._get_power, poll_period=0.2) + self.ramp_rate = AttrRW( + Float(), + getter=self._get_ramp_rate, + setter=self._set_ramp_rate, + poll_period=0.2, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -83,6 +105,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..05d6298c0 100644 --- a/docs/snippets/static13.py +++ b/docs/snippets/static13.py @@ -1,11 +1,10 @@ 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 from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String @@ -14,38 +13,23 @@ 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 +37,68 @@ 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=self._get_start, setter=self._set_start, poll_period=0.2 + ) + self.end = AttrRW( + Int(), getter=self._get_end, setter=self._set_end, poll_period=0.2 ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=self._get_enabled, + setter=self._set_enabled, + poll_period=0.2, + ) + self.target = AttrR(Float(), getter=self._get_target, poll_period=0.2) + self.actual = AttrR(Float(), getter=self._get_actual, poll_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(String(), getter=self._get_device_id, poll_period=0.2) + self.power = AttrR(Float(), getter=self._get_power, poll_period=0.2) + self.ramp_rate = AttrRW( + Float(), + getter=self._get_ramp_rate, + setter=self._set_ramp_rate, + poll_period=0.2, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -84,6 +106,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 +132,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..7850780ab 100644 --- a/docs/snippets/static14.py +++ b/docs/snippets/static14.py @@ -1,11 +1,10 @@ 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 from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String @@ -15,40 +14,26 @@ 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 +41,68 @@ 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=self._get_start, setter=self._set_start, poll_period=0.2 + ) + self.end = AttrRW( + Int(), getter=self._get_end, setter=self._set_end, poll_period=0.2 ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=self._get_enabled, + setter=self._set_enabled, + poll_period=0.2, + ) + self.target = AttrR(Float(), getter=self._get_target, poll_period=0.2) + self.actual = AttrR(Float(), getter=self._get_actual, poll_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(String(), getter=self._get_device_id, poll_period=0.2) + self.power = AttrR(Float(), getter=self._get_power, poll_period=0.2) + self.ramp_rate = AttrRW( + Float(), + getter=self._get_ramp_rate, + setter=self._set_ramp_rate, + poll_period=0.2, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -87,6 +110,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 +137,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..d6d031754 100644 --- a/docs/snippets/static15.py +++ b/docs/snippets/static15.py @@ -1,11 +1,10 @@ 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 from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String @@ -15,42 +14,29 @@ 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") + 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]) -> 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) + logger.trace("Query for attribute", query=query, response=value) - await self._connection.send_command(f"{command}\r\n") + return value class OnOffEnum(enum.StrEnum): @@ -59,30 +45,68 @@ 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=self._get_start, setter=self._set_start, poll_period=0.2 + ) + self.end = AttrRW( + Int(), getter=self._get_end, setter=self._set_end, poll_period=0.2 ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=self._get_enabled, + setter=self._set_enabled, + poll_period=0.2, + ) + self.target = AttrR(Float(), getter=self._get_target, poll_period=0.2) + self.actual = AttrR(Float(), getter=self._get_actual, poll_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(String(), getter=self._get_device_id, poll_period=0.2) + self.power = AttrR(Float(), getter=self._get_power, poll_period=0.2) + self.ramp_rate = AttrRW( + Float(), + getter=self._get_ramp_rate, + setter=self._set_ramp_rate, + poll_period=0.2, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -90,6 +114,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) @@ -105,7 +141,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/src/fastcs/attributes/__init__.py b/src/fastcs/attributes/__init__.py index d0f5e59f0..bfc25bf4c 100644 --- a/src/fastcs/attributes/__init__.py +++ b/src/fastcs/attributes/__init__.py @@ -1,10 +1,9 @@ from .attr_r import AttrR as AttrR +from .attr_r import Getter as Getter 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 .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..a601781d6 --- /dev/null +++ b/src/fastcs/attributes/_infer_datatype.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import enum +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 Bool, DataType, Enum, Float, Int, String + +_DEFAULT_DATATYPES: dict[type, Callable[[], DataType]] = { + int: Int, + float: Float, + bool: Bool, + str: String, +} + + +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_type(py_type: Any) -> DataType | None: + if py_type in _DEFAULT_DATATYPES: + return _DEFAULT_DATATYPES[py_type]() + if isinstance(py_type, type) and issubclass(py_type, enum.Enum): + return Enum(py_type) + return None + + +def infer_datatype_from_getter(getter: Callable) -> DataType | None: + """Infer a default ``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_type(_unwrap_update_annotation(annotation)) + + +def infer_datatype_from_setter(setter: Callable) -> DataType | None: + """Infer a default ``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_type(annotation) diff --git a/src/fastcs/attributes/attr_r.py b/src/fastcs/attributes/attr_r.py index 28d66c07b..3dd9a77ec 100644 --- a/src/fastcs/attributes/attr_r.py +++ b/src/fastcs/attributes/attr_r.py @@ -1,40 +1,55 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from typing import Any +from collections.abc import Awaitable, Callable, Coroutine +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.update import Update from fastcs.attributes.util import AttrValuePredicate, PredicateEvent from fastcs.datatypes import DataType, DType_T 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""" +Getter = Callable[[], Awaitable["DType_T | Update[DType_T]"]] +"""A callable that fetches a fresh value for an attribute from its source""" AttrOnUpdateCallback = Callable[[DType_T], Coroutine[None, None, None]] """A callback to be called when the value of the attribute is updated""" +_UNSET = object() -class AttrR(Attribute[DType_T, AttributeIORefT]): + +class AttrR(Attribute[DType_T]): """A read-only ``Attribute``""" def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, + datatype: DataType[DType_T] | None = None, + getter: Getter[DType_T] | None = None, + poll_period: float | None = _UNSET, # type: ignore[assignment] group: str | None = None, initial_value: DType_T | None = None, description: str | None = None, ) -> None: - super().__init__(datatype, io_ref, group, description=description) + if datatype is None: + datatype = getter and infer_datatype_from_getter(getter) + if datatype is None: + raise ValueError( + "datatype must be given explicitly, or be inferable from the " + "getter's return type annotation" + ) + + Attribute.__init__(self, datatype, group, description=description) self._value: DType_T = ( datatype.initial_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._getter = getter + self._poll_period: float | None = ( + (ONCE if getter is not None else None) + if poll_period is _UNSET + else poll_period + ) + """Period in seconds between calls to poll(), or ONCE, or None (on-demand)""" self._on_update_callbacks: ( list[tuple[AttrOnUpdateCallback[DType_T], bool]] | None ) = None @@ -42,34 +57,45 @@ def __init__( 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 + 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. 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 """ + if isinstance(value, Update): + value = value.value + self.log_event("Attribute set", value=repr(value), attribute=self) _previous_value = self._value @@ -101,6 +127,16 @@ async def update(self, value: Any) -> None: ) raise + 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") + + self.log_event("Poll attribute", topic=self) + result = await self._getter() + await self.update(result) + return self._value + def add_on_update_callback( self, callback: AttrOnUpdateCallback[DType_T], always: bool = False ) -> None: @@ -113,30 +149,6 @@ def add_on_update_callback( self._on_update_callbacks = [] self._on_update_callbacks.append((callback, always)) - 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 converted to an async task and called periodically. - - """ - 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 - 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..297c1fe87 100644 --- a/src/fastcs/attributes/attr_rw.py +++ b/src/fastcs/attributes/attr_rw.py @@ -1,42 +1,79 @@ -from fastcs.attributes.attr_r import AttrR -from fastcs.attributes.attr_w import AttrW +from __future__ import annotations + +from fastcs.attributes._infer_datatype import ( + infer_datatype_from_getter, + infer_datatype_from_setter, +) +from fastcs.attributes.attr_r import AttrR, Getter +from fastcs.attributes.attr_w import AttrW, Setter from fastcs.attributes.attribute import AttributeAccessMode -from fastcs.attributes.attribute_io_ref import AttributeIORefT +from fastcs.attributes.update import Update from fastcs.datatypes import DataType, DType_T +from fastcs.logging import logger + +_UNSET = object() -class AttrRW(AttrR[DType_T, AttributeIORefT], AttrW[DType_T, AttributeIORefT]): +class AttrRW(AttrR[DType_T], AttrW[DType_T]): """A read-write ``Attribute``.""" def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, + datatype: DataType[DType_T] | None = None, + getter: Getter[DType_T] | None = None, + setter: Setter[DType_T] | None = None, + poll_period: float | None = _UNSET, # type: ignore[assignment] group: str | None = None, initial_value: DType_T | None = None, description: str | None = None, ): - super().__init__(datatype, io_ref, group, initial_value, description) - - self._setpoint_initialised = False + if datatype is None: + datatype = (getter and infer_datatype_from_getter(getter)) or ( + setter and infer_datatype_from_setter(setter) + ) + if datatype is None: + raise ValueError( + "datatype must be given explicitly, or be inferable from the " + "getter/setter annotations" + ) - if io_ref is None: - self.set_on_put_callback(self._internal_update) + AttrR.__init__( + self, datatype, getter, poll_period, group, initial_value, description + ) + AttrW.__init__(self, datatype, setter, group, description) + # Soft/RW attrs start with setpoint == readback, rather than the datatype's + # generic initial_value. + self._setpoint = self._value @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 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 non-``None`` return value is + additionally applied to the readback - the sanctioned replacement for the + old private setpoint-echo mechanism. + + """ + value = self._datatype.validate(value) + self._setpoint = value - async def update(self, value: DType_T): - await super().update(value) + if self._setter is None: + await self.update(value) + else: + try: + result = await self._setter(value) + except Exception as e: + logger.opt(exception=e).error( + "Set failed", attribute=self, setpoint=value + ) + else: + if result is not None: + accepted = result.value if isinstance(result, Update) else result + self._setpoint = accepted + await self.update(accepted) - if not self._setpoint_initialised: - await self._call_sync_setpoint_callbacks(self._value) - self._setpoint_initialised = True + 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..4dca17f9e 100644 --- a/src/fastcs/attributes/attr_w.py +++ b/src/fastcs/attributes/attr_w.py @@ -1,98 +1,80 @@ -import asyncio +from __future__ import annotations + from collections.abc import Awaitable, Callable -from typing import Any +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.attributes.update import Update from fastcs.datatypes import DataType, DType_T 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""" -class AttrW(Attribute[DType_T, AttributeIORefT]): +class AttrW(Attribute[DType_T]): """A write-only ``Attribute``.""" def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, + datatype: DataType[DType_T] | None = None, + setter: Setter[DType_T] | None = None, group: str | None = None, description: str | None = None, ) -> 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]] = [] - """Callbacks to publish changes to the setpoint of the attribute""" + if datatype is None: + datatype = setter and infer_datatype_from_setter(setter) + if datatype is None: + raise ValueError( + "datatype must be given explicitly, or be inferable from the " + "setter's value parameter annotation" + ) + + Attribute.__init__(self, datatype, group, description=description) + self._setter = setter + self._setpoint: DType_T = datatype.initial_value + + @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 + 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. 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. + 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 non-``None`` value, that is treated as the source's + accepted/clamped value and becomes the new cached setpoint. - 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`. + 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``. """ - setpoint = self._datatype.validate(setpoint) - if self._on_put_callback is not None: - try: - await self._on_put_callback(self, setpoint) - except Exception as e: - logger.opt(exception=e).error( - "Put failed", attribute=self, setpoint=setpoint - ) + value = self._datatype.validate(value) + self._setpoint = value - if sync_setpoint: + if self._setter is not None: try: - await self._call_sync_setpoint_callbacks(setpoint) + result = await self._setter(value) except Exception as e: logger.opt(exception=e).error( - "Sync setpoint failed", attribute=self, setpoint=setpoint + "Set failed", attribute=self, setpoint=value ) + else: + if result is not None: + self._setpoint = ( + result.value if isinstance(result, Update) else result + ) - 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..4677ebbb5 100644 --- a/src/fastcs/attributes/attribute.py +++ b/src/fastcs/attributes/attribute.py @@ -2,14 +2,13 @@ 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 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. @@ -18,7 +17,6 @@ class Attribute(Generic[DType_T, AttributeIORefT], Tracer, ABC): def __init__( self, datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, group: str | None = None, description: str | None = None, ) -> None: @@ -27,7 +25,6 @@ def __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 self.enabled = True @@ -41,15 +38,6 @@ def __init__( 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 - @property def datatype(self) -> DataType[DType_T]: return self._datatype @@ -115,4 +103,4 @@ def __repr__(self): 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}, datatype={datatype})" 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/update.py b/src/fastcs/attributes/update.py new file mode 100644 index 000000000..f9853d80a --- /dev/null +++ b/src/fastcs/attributes/update.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic + +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/setter may return a bare value, or wrap it in ``Update`` to also + carry the timestamp the value was obtained at (``None`` means the framework + should stamp receive-time). + """ + + value: DType_T + timestamp: float | None = None diff --git a/src/fastcs/controllers/base_controller.py b/src/fastcs/controllers/base_controller.py index 8b29ed6d2..725b02e53 100755 --- a/src/fastcs/controllers/base_controller.py +++ b/src/fastcs/controllers/base_controller.py @@ -1,7 +1,5 @@ from __future__ import annotations -from collections import Counter -from collections.abc import Sequence from copy import deepcopy from typing import ( TypeVar, @@ -11,7 +9,7 @@ get_type_hints, ) -from fastcs.attributes import AnyAttributeIO, Attribute, AttrR, AttrW, HintedAttribute +from fastcs.attributes import Attribute, HintedAttribute from fastcs.controllers.controller_api import ControllerAPI from fastcs.logging import logger from fastcs.methods import Command, Method, Scan, UnboundCommand, UnboundScan @@ -41,7 +39,6 @@ def __init__( self, path: list[str] | None = None, description: str | None = None, - ios: Sequence[AnyAttributeIO] | None = None, ) -> None: super().__init__() @@ -64,10 +61,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 +74,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( @@ -156,16 +149,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 +175,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 +242,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.""" diff --git a/src/fastcs/controllers/controller.py b/src/fastcs/controllers/controller.py index a6c726027..b03793db6 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): @@ -83,14 +80,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/demo/controllers.py b/src/fastcs/demo/controllers.py index 5926fc8ce..0ec0402c5 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -1,19 +1,19 @@ import asyncio import enum import json -from dataclasses import KW_ONLY, dataclass +from dataclasses import dataclass from typing import TypeVar import numpy as np -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW 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) +ValueT = TypeVar("ValueT") class OnOffEnum(enum.StrEnum): @@ -27,56 +27,40 @@ class TemperatureControllerSettings: 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__() +class TemperatureProtocol: + """Getter/setter callables for the temperature controller's text protocol""" + def __init__(self, connection: IPConnection, suffix: str = ""): self._connection = connection - self.suffix = suffix + 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)}" + 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") - self.log_event("Send command for attribute", topic=attr, command=command) + logger.trace("Send command for attribute", command=command) - async def update( - self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef] - ) -> None: - query = f"{attr.io_ref.name}{self.suffix}?" + 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") response = response.strip("\r\n") - self.log_event( - "Query for attribute", - topic=attr, - query=query, - response=response, - ) - - await attr.update(attr.dtype(response)) + logger.trace("Query for attribute", query=query, response=response) + return dtype(response) # type: ignore[call-arg] 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._protocol = TemperatureProtocol(self.connection) + super().__init__() + + self.ramp_rate = AttrRW( + Float(), + getter=self._get_ramp_rate, + setter=self._set_ramp_rate, + poll_period=0.2, ) + self.power = AttrR(Float(), getter=self._get_power, poll_period=0.2) + self.voltages = AttrR(Waveform(np.int32, shape=(4,))) self._settings = settings @@ -86,10 +70,19 @@ def __init__(self, settings: TemperatureControllerSettings) -> None: self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + 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 _get_power(self) -> float: + return await self._protocol.send_query("P", float) + @command() async def cancel_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) @@ -129,18 +122,48 @@ async def update_voltages(self): 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._protocol = TemperatureProtocol(conn, suffix) + super().__init__(f"Ramp{suffix}") self.connection = conn + + self.start = AttrRW( + Int(), getter=self._get_start, setter=self._set_start, poll_period=0.2 + ) + self.end = AttrRW( + Int(), getter=self._get_end, setter=self._set_end, poll_period=0.2 + ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=self._get_enabled, + setter=self._set_enabled, + poll_period=0.2, + ) + self.target = AttrR(Float(prec=3), getter=self._get_target, poll_period=0.2) + self.actual = AttrR(Float(prec=3), getter=self._get_actual, poll_period=0.2) + self.voltage = AttrR(Float(prec=3)) + + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) + + 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) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 536cdcfa5..57a02e7d4 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -229,7 +229,7 @@ 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.datatype, value)) async def set_setpoint_without_process(value: DType_T): tracer.log_event( @@ -244,7 +244,18 @@ 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) + if isinstance(attribute, AttrR): + # AttrRW: seed the setpoint record with the first known readback value + # (e.g. from a getter poll), in case it differs from the datatype default. + seeded = False + + async def seed_setpoint_once(value: DType_T): + nonlocal seeded + if not seeded: + seeded = True + await set_setpoint_without_process(value) + + attribute.add_on_update_callback(seed_setpoint_once) def _create_and_link_command_pvs( diff --git a/src/fastcs/transports/epics/ca/util.py b/src/fastcs/transports/epics/ca/util.py index 6a3e6dd83..c6473afbf 100644 --- a/src/fastcs/transports/epics/ca/util.py +++ b/src/fastcs/transports/epics/ca/util.py @@ -73,7 +73,7 @@ def validate_ca_id(controller_api: ControllerAPI) -> None: def _make_in_record(pv: str, attribute: AttrR) -> RecordWrapper: common_fields = { "DESC": attribute.description, - "initial_value": cast_to_epics_type(attribute.datatype, attribute.get()), + "initial_value": cast_to_epics_type(attribute.datatype, attribute.readback), } match attribute.datatype: @@ -139,7 +139,7 @@ def _make_out_record(pv: str, attribute: AttrW, on_update: Callable) -> RecordWr "DESC": attribute.description, "initial_value": cast_to_epics_type( attribute.datatype, - attribute.get() + attribute.readback if isinstance(attribute, AttrRW) else attribute.datatype.initial_value, ), diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index 5ba819f98..ad458b3b7 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -53,7 +53,7 @@ async def put(self, pv: SharedPV, op: ServerOperation): else: pv.post(value) - await self._attr_w.put(cast_value) + await self._attr_w.set(cast_value) op.done() @@ -121,7 +121,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), ) @@ -145,7 +145,18 @@ 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) + if isinstance(attribute, AttrR): + # AttrRW: seed the setpoint PV with the first known readback value (e.g. + # from a getter poll), in case it differs from the datatype default. + seeded = False + + async def seed_setpoint_once(value): + nonlocal seeded + if not seeded: + seeded = True + await set_setpoint(value) + + attribute.add_on_update_callback(seed_setpoint_once) return shared_pv diff --git a/src/fastcs/transports/graphql/graphql.py b/src/fastcs/transports/graphql/graphql.py index 871e5c905..0c74ad27d 100644 --- a/src/fastcs/transports/graphql/graphql.py +++ b/src/fastcs/transports/graphql/graphql.py @@ -144,7 +144,7 @@ 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 @@ -161,7 +161,7 @@ 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 diff --git a/src/fastcs/transports/rest/rest.py b/src/fastcs/transports/rest/rest.py index 522246b2e..a8ca359d8 100644 --- a/src/fastcs/transports/rest/rest.py +++ b/src/fastcs/transports/rest/rest.py @@ -69,7 +69,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.datatype, request.value)) # Fast api uses type annotations for validation, schema, conversions attr_put.__annotations__["request"] = _put_request_body(attribute) @@ -95,7 +95,7 @@ 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() + value = attribute.readback return {"value": cast_to_rest_type(attribute.datatype, value)} return attr_get diff --git a/src/fastcs/transports/tango/dsr.py b/src/fastcs/transports/tango/dsr.py index 93aaab1c8..553fe3d92 100644 --- a/src/fastcs/transports/tango/dsr.py +++ b/src/fastcs/transports/tango/dsr.py @@ -30,7 +30,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.datatype, attribute.readback) return fget @@ -54,7 +54,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.datatype, value)) await _run_threadsafe_blocking(coro, loop) return fset diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index c57916134..d429abd7a 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -1,44 +1,24 @@ 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.datatypes import 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 +77,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/conftest.py b/tests/conftest.py index 818c7d178..9c6563dfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ 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,11 +42,11 @@ 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_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(), io_ref=MyTestAttributeIORef()) + write_bool: AttrW = AttrW(Bool()) read_string: AttrRW = AttrRW(String()) diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index 95cc8e70b..5951ef6bf 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -1,28 +1,16 @@ import asyncio import enum -from dataclasses import dataclass 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 Bool, Enum, Float, Int, Table, Waveform from fastcs.launch import FastCS from fastcs.methods import command, scan 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 @@ -33,39 +21,30 @@ class FEnum(enum.Enum): class ParentController(Controller): description = "some controller" - a: AttrRW = AttrRW( - Int(max=400_000, max_alarm=40_000), io_ref=SimpleAttributeIORef() - ) - b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5), io_ref=SimpleAttributeIORef()) + a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) + b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5)) table: AttrRW = AttrRW( Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]), - io_ref=SimpleAttributeIORef(), ) - 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()) - - def __init__(self, description=None, ios=None): - super().__init__(description, ios) + c: AttrW = AttrW(Int()) @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,))) @@ -81,15 +60,14 @@ 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()) def run(id="P4P_TEST_DEVICE"): - simple_attribute_io = SimpleAttributeIO() p4p_options = EpicsPVATransport() - controller = ParentController(ios=[simple_attribute_io]) + controller = ParentController() controller.set_path([id]) class ChildVector(ControllerVector): @@ -100,12 +78,8 @@ def __init__(self, children, description=None): 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/test_attribute_logging.py b/tests/test_attribute_logging.py index e24a47db4..6e5dff098 100644 --- a/tests/test_attribute_logging.py +++ b/tests/test_attribute_logging.py @@ -33,7 +33,7 @@ async def test_attr_r_update_logs_validation_error(loguru_caplog): 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 diff --git a/tests/test_attributes.py b/tests/test_attributes.py index cdd428911..452aaf5aa 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,14 +1,13 @@ import asyncio -from dataclasses import dataclass from functools import partial -from typing import Generic, TypeVar import pytest from pytest_mock import MockerFixture -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, AttrW, Update from fastcs.controllers import Controller from fastcs.datatypes import Float, Int, String +from fastcs.util import ONCE def test_attribute_access_mode(): @@ -26,10 +25,8 @@ def test_attribute_access_mode(): def test_attr_r(): attr = AttrR(String(), group="test group") - with pytest.raises(RuntimeError): - _ = attr.io_ref - - assert not attr.has_io_ref() + assert not attr.has_getter() + assert attr.poll_period is None assert isinstance(attr.datatype, String) assert attr.dtype == str assert attr.group == "test group" @@ -47,34 +44,112 @@ def test_attr_r(): with pytest.raises(RuntimeError): 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 isinstance(attr.datatype, Float) + + +def test_datatype_inferred_from_setter_annotation(): + async def set_value(value: int) -> None: + pass + + attr = AttrW(setter=set_value) + assert isinstance(attr.datatype, Int) + + +def test_datatype_required_when_not_inferable(): + with pytest.raises(ValueError, match="datatype must be given explicitly"): + AttrR() + + with pytest.raises(ValueError, match="datatype must be given explicitly"): + AttrW() + + with pytest.raises(ValueError, match="datatype must be given explicitly"): + AttrRW() @pytest.mark.asyncio -async def test_attr_update(mocker: MockerFixture): +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 + + +@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()) - await attr.update("200") # type: ignore - assert attr.get() == 200 - sync_setpoint_mock.assert_called_once_with(200) + with pytest.raises(RuntimeError): + 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): + await attr.poll() + + +def test_poll_period_defaults_to_once_when_getter_given(): + async def do_update(): + return 1 + + attr = AttrR(Int(), getter=do_update) + assert attr.poll_period == ONCE + + attr_explicit = AttrR(Int(), getter=do_update, poll_period=0.5) + assert attr_explicit.poll_period == 0.5 + + attr_on_demand = AttrR(Int(), getter=do_update, poll_period=None) + assert attr_on_demand.poll_period is None - sync_setpoint_mock.reset_mock() - await attr.update(20) - assert attr.get() == 20 - sync_setpoint_mock.assert_not_called() + attr_no_getter = AttrR(Int()) + assert attr_no_getter.poll_period is None @pytest.mark.asyncio @@ -84,7 +159,7 @@ async def test_wait_for_predicate(mocker: MockerFixture): 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)) @@ -131,17 +206,15 @@ 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 - - async def device_add(): - device["number"] += 1 + return value # accepted value echoes straight back to the readback attr_r = AttrR(String()) attr_r.add_on_update_callback(partial(update_ui, key="state"), always=False) @@ -153,50 +226,74 @@ 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_on_update_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() - class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): - async def update(self, attr: AttrR[int, MyAttributeIORef]): - print("I am updating", self.ref_type, attr.io_ref.cool) + await attr.set(40) + assert attr.setpoint == 40 + assert attr.readback == 40 - class MyController(Controller): - my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) - your_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=10)) - def __init__(self): - super().__init__(ios=[MyAttributeIO()]) +@pytest.mark.asyncio +async def test_setter_return_value_updates_readback(): + accepted = {} - c = MyController() + async def setter(value): + accepted["value"] = value + return value + 1 # device clamps/accepts a different value - class ControllerNoIO(Controller): - my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) + attr = AttrRW(Int(), setter=setter) - @dataclass - class OtherAttributeIORef(AttributeIORef): - not_cool: int + await attr.set(10) + assert accepted["value"] == 10 + assert attr.setpoint == 11 + assert attr.readback == 11 - class MissingIOController(Controller): - my_attr = AttrR(Int(), io_ref=OtherAttributeIORef(not_cool=5)) - with pytest.raises(ValueError, match="does not have an AttributeIO to handle"): - controller = MissingIOController() - controller._connect_attribute_ios() +@pytest.mark.asyncio +async def test_setter_with_no_return_leaves_readback_untouched(): + async def setter(value): + return None - await c.initialise() - c._connect_attribute_ios() - await c.my_attr.bind_update_callback()() + attr = AttrRW(Int(), setter=setter) + + 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 + + +@pytest.mark.asyncio +async def test_set_setter_exception_is_caught_and_logged(): + async def do_set(value): + raise ValueError("do_set failed") + + attr = AttrW(Int(), setter=do_set) + + # exception is caught and logged, not raised + await attr.set(5) + assert attr.setpoint == 5 class DummyConnection: @@ -264,38 +361,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 @@ -312,22 +379,36 @@ 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"]]( + min=parameter_response.get("min", None), + max=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), + ) + 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), + ) + + self.add_attribute(name, attr) except Exception as e: print( "Exception constructing attribute from parameter response:", @@ -335,130 +416,11 @@ async def initialise(self): e, ) - c = DemoParameterController(ios=[DemoParameterAttributeIO()]) - 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 - - -@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() - + c = DemoParameterController() await c.initialise() - c._connect_attribute_ios() - - # 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 - - sync_setpoint_mock = mocker.AsyncMock() - c.no_ref.add_sync_setpoint_callback(sync_setpoint_mock) - - 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()]) - - await c2.initialise() - c2._connect_attribute_ios() - - assert c2.base_class_ref.get() == 0 - await c2.base_class_ref.bind_update_callback()() - assert c2.base_class_ref.get() == 100 - - -def test_add_update_callback_twice_raises(): - async def do_update(attr: AttrR[int]): - pass - - attr = AttrRW(Int()) - attr.set_update_callback(do_update) - - with pytest.raises(RuntimeError): - attr.set_update_callback(do_update) + assert await c.ro_int_parameter.poll() == 10 + assert await c.ro_int_parameter.poll() == 11 -@pytest.mark.asyncio -async def test_bind_update(): - attr = AttrRW(Int()) - - with pytest.raises(RuntimeError): - attr.bind_update_callback() - - async def do_update(attr: AttrR[int]): - await attr.update(5) - - attr.set_update_callback(do_update) - callback = attr.bind_update_callback() - - await callback() - assert attr.get() == 5 - - -@pytest.mark.asyncio -async def test_bind_update_exception(): - attr = AttrRW(Int()) - - async def do_update(attr: AttrR[int]): - raise ValueError("do_update failed") - - attr.set_update_callback(do_update) - - callback = attr.bind_update_callback() - - with pytest.raises(ValueError): - await callback() - - -@pytest.mark.asyncio -async def test_put(): - attr = AttrW(Int()) - - async def do_put(attr: AttrW[int], value: int): - raise ValueError("do_put failed") - - async def do_sync_setpoint(setpoint: int): - raise ValueError("do_sync_setpoint failed") - - attr.set_on_put_callback(do_put) - attr.add_sync_setpoint_callback(do_sync_setpoint) - - await attr.put(5) - - with pytest.raises(RuntimeError): - attr.set_on_put_callback(do_put) + await c.int_parameter.set(20) + assert c.int_parameter.readback == 20 diff --git a/tests/test_control_system.py b/tests/test_control_system.py index ca151cc02..b4e5b479e 100644 --- a/tests/test_control_system.py +++ b/tests/test_control_system.py @@ -1,9 +1,8 @@ import asyncio -from dataclasses import dataclass import pytest -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.attributes import AttrR from fastcs.control_system import FastCS from fastcs.controllers import Controller from fastcs.datatypes import Int @@ -59,40 +58,42 @@ 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=get_once, poll_period=ONCE) + self.update_quickly = AttrR(Int(), getter=get_quickly, poll_period=0.1) + self.update_never = AttrR(Int(), getter=get_never, poll_period=None) + + 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 diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py index 8b7c12205..9ce454d42 100644 --- a/tests/transports/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -8,7 +8,6 @@ from softioc import softioc from tests.assertable_controller import ( AssertableControllerAPI, - MyTestAttributeIORef, MyTestController, ) from tests.util import ColourEnum @@ -228,27 +227,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_on_update_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 readback-seeding callback generated and set in the function + attribute.add_on_update_callback.assert_called_once_with(mocker.ANY) + seed_setpoint_callback = attribute.add_on_update_callback.call_args[0][0] + await seed_setpoint_callback(1) record.set.assert_called_once_with(1, process=False) + # Calling it again should not seed the record a second time + record.set.reset_mock() + await seed_setpoint_callback(2) + record.set.assert_not_called() + # 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): @@ -343,11 +347,11 @@ def test_get_output_record_raises(mocker: MockerFixture): class EpicsController(MyTestController): - read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) - read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) + read_int = AttrR(Int()) + read_write_int = AttrRW(Int()) read_write_float = AttrRW(Float()) read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) + 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,))) diff --git a/tests/transports/graphQL/test_graphql.py b/tests/transports/graphQL/test_graphql.py index 46d2fc8a9..193d081cd 100644 --- a/tests/transports/graphQL/test_graphql.py +++ b/tests/transports/graphQL/test_graphql.py @@ -8,7 +8,6 @@ from pytest_mock import MockerFixture from tests.assertable_controller import ( AssertableControllerAPI, - MyTestAttributeIORef, MyTestController, ) @@ -18,11 +17,11 @@ class GraphQLController(MyTestController): - read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) - read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) + read_int = AttrR(Int()) + read_write_int = AttrRW(Int()) read_write_float = AttrRW(Float()) read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) + write_bool = AttrW(Bool()) read_string = AttrRW(String()) diff --git a/tests/transports/rest/test_rest.py b/tests/transports/rest/test_rest.py index 80af6698b..2f458cb05 100644 --- a/tests/transports/rest/test_rest.py +++ b/tests/transports/rest/test_rest.py @@ -98,8 +98,8 @@ 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) + 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..1eef2c242 100644 --- a/tests/transports/tango/test_dsr.py +++ b/tests/transports/tango/test_dsr.py @@ -149,8 +149,8 @@ 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) + 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 From d38e78a7a3d06a289562d0a8c3a18a57235a5b9c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 22:49:25 +0000 Subject: [PATCH 2/2] docs: rewrite AttributeIO tutorial/how-to content for getter/setter API The docs build failed CI (fail-on-warning) because docs/tutorials/static-drivers.md's literalinclude emphasize-lines directives pointed at line numbers that no longer existed after the snippet rewrite. Fixing that surfaced the deeper issue: several tutorial and how-to pages narrated the removed AttributeIO/AttributeIORef pattern in prose, with code examples that no longer import. - Rewrite docs/tutorials/static-drivers.md and dynamic-drivers.md prose + literalinclude line references to match the getter/setter snippets. - Give docs/snippets/static15.py's TemperatureProtocol a Tracer base and thread `topic` through send_query, so the tutorial's per-attribute tracing walkthrough (enable_tracing on one attribute, see only its queries) still holds - a plain logger.trace call wouldn't respect per-attribute enable_tracing() at all. - Rewrite docs/how-to/update-attributes-from-device.md's four patterns (poll via getter, event-driven updates from a set, batched scan updates, scan-as-cache) for getter/setter. - Fix remaining AttributeIO/.get()/.put()/update_period mentions in docs/explanations/{transports,controllers,what-is-fastcs,datatypes}.md and docs/how-to/{table-waveform-data,wait-methods}.md. --- docs/explanations/controllers.md | 7 +- docs/explanations/datatypes.md | 8 +- docs/explanations/transports.md | 55 ++--- docs/explanations/what-is-fastcs.md | 5 +- docs/how-to/table-waveform-data.md | 2 +- docs/how-to/update-attributes-from-device.md | 210 ++++++++----------- docs/how-to/wait-methods.md | 2 +- docs/snippets/static15.py | 26 ++- docs/tutorials/dynamic-drivers.md | 18 +- docs/tutorials/static-drivers.md | 158 +++++++------- 10 files changed, 237 insertions(+), 254 deletions(-) diff --git a/docs/explanations/controllers.md b/docs/explanations/controllers.md index 2378144d2..2fda70a2d 100644 --- a/docs/explanations/controllers.md +++ b/docs/explanations/controllers.md @@ -28,8 +28,9 @@ 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 with a `getter` and a `poll_period` set, across the whole controller +hierarchy, to be run as background tasks by FastCS. Scan tasks are gated on the +`_connected` flag: if a scan raises an exception, `_connected` is set to `False` and tasks pause until `reconnect` sets it back to `True`. @@ -154,7 +155,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..fb1d81740 100644 --- a/docs/explanations/datatypes.md +++ b/docs/explanations/datatypes.md @@ -175,7 +175,7 @@ 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 @@ -188,9 +188,9 @@ attr = AttrRW(Int(min=0, max=10), initial_value=5) 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 diff --git a/docs/explanations/transports.md b/docs/explanations/transports.md index bf34a2fcc..9a4f5e737 100644 --- a/docs/explanations/transports.md +++ b/docs/explanations/transports.md @@ -99,9 +99,8 @@ 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 | +| Set | `attr.set(value)` | Transport receives user input | Set ↓ | Forward write requests from protocol to attribute | ### On Update Callbacks @@ -140,13 +139,15 @@ def create_read(name, attribute): The callback receives the new `DataType` instance and should update the protocol's metadata representation (e.g., EPICS record fields like `EGU`, `HOPR`, `LOPR`). -### Put +### Set 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. +attribute.set(value)` to forward it to the attribute. This triggers validation, caches +the value as the attribute's `.setpoint`, and (if the attribute has one) runs its +`setter` to propagate the value to the device. If the setter returns a non-`None` value, +that becomes the attribute's new `.setpoint`/readback - the device's accepted or +clamped value. The transport should also update its own setpoint display directly +rather than relying on a callback. ```python def create_write(name, attribute): @@ -154,35 +155,39 @@ def create_write(name, attribute): async def handle_write(value): protocol_setpoint.post(value) - await attribute.put(value) + await attribute.set(value) ``` -### Sync Setpoint Callbacks +### Seeding a Setpoint Display from the Readback -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. +An `AttrRW`'s setpoint starts out equal to its datatype's default, which may not match +the device's actual current value until the first poll happens. To avoid a write PV +briefly displaying a stale default, seed it once the first readback value is known, +using a one-shot `add_on_update_callback()` on the readback side: ```python +from fastcs.attributes import AttrR + + def create_write(name, attribute): + protocol_setpoint = Protocol(name) + ... - async def update_setpoint_display(value): - protocol_setpoint.post(value) + if isinstance(attribute, AttrR): + seeded = False - attribute.add_sync_setpoint_callback(update_setpoint_display) -``` + async def seed_setpoint_once(value): + nonlocal seeded + if not seeded: + seeded = True + protocol_setpoint.post(value) -Sync setpoint callbacks are used in specific cases: + attribute.add_on_update_callback(seed_setpoint_once) +``` -- 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 +This only applies to `AttrRW` (readable and writable) - a pure `AttrW` has no readback +to seed a setpoint display from. ## 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/table-waveform-data.md b/docs/how-to/table-waveform-data.md index d7a7572bc..8a768604f 100644 --- a/docs/how-to/table-waveform-data.md +++ b/docs/how-to/table-waveform-data.md @@ -129,7 +129,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/update-attributes-from-device.md b/docs/how-to/update-attributes-from-device.md index 895eb5f2a..8abb97da6 100644 --- a/docs/how-to/update-attributes-from-device.md +++ b/docs/how-to/update-attributes-from-device.md @@ -3,129 +3,113 @@ 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` and FastCS will call it periodically as a background task, at +the rate set by `poll_period`. -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 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=self._get_temperature, poll_period=0.5) + self.setpoint = AttrRW( + Float(), + getter=self._get_setpoint, + setter=self._set_setpoint, + poll_period=1.0, + ) + self.label = AttrR(String(), getter=self._get_label, poll_period=None) - 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: +Setting `poll_period` to: - A positive `float` — polls at that interval in seconds. -- `None` — no automatic updates; the attribute value is only set explicitly (e.g. from a - scan method or subscription callback). -- `ONCE` (imported from `fastcs`) — called once on startup and not again. +- `None` — no automatic polling; the attribute value is only set explicitly (e.g. from a + scan method or subscription callback), or read on-demand via `await attr.poll()`. +- `ONCE` (imported from `fastcs`, and the default when a `getter` is given) — called once + on startup and not again. -## Initial Read with Event-Driven Updates from Puts +## Initial Read with Event-Driven Updates from Sets -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. +Use this pattern when attributes need their initial value read on startup, but +subsequent updates arrive as side-effects of write operations rather than on a fixed +poll cycle. This is common for devices that echo back related parameter values in their +response to a set command. -Set `update_period=ONCE` on the `AttributeIORef` so that `AttributeIO.update` is called -once when the application starts. Then, in `AttributeIO.send`, parse the device's -response to the put and call `attr.update` on any attributes whose values have changed: +Leave `poll_period=ONCE` (the default when a `getter` is given) so the getter runs once +on startup. Then, in the setter, parse the device's response and call `.update()` +directly on any sibling attributes whose values have changed: ```python -from collections.abc import Awaitable, Callable -from dataclasses import KW_ONLY, dataclass - -from fastcs import ONCE -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.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) -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 _get_setpoint(self) -> float: + return float((await self._connection.send_query("S?\r\n")).strip()) - 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 leave +`poll_period=ONCE` so they also get their initial value on startup. Set +`poll_period=None` 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,8 +117,9 @@ 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` or `poll_period` because +the scan method drives the updates directly, rather than each attribute polling +independently. ```python import json @@ -146,7 +131,7 @@ 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 +163,53 @@ 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=self._get_voltage, poll_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 diff --git a/docs/how-to/wait-methods.md b/docs/how-to/wait-methods.md index d61fb8bec..e9ef09b94 100644 --- a/docs/how-to/wait-methods.md +++ b/docs/how-to/wait-methods.md @@ -20,7 +20,7 @@ class MotorController(Controller): @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) diff --git a/docs/snippets/static15.py b/docs/snippets/static15.py index d6d031754..60f707ca3 100644 --- a/docs/snippets/static15.py +++ b/docs/snippets/static15.py @@ -11,14 +11,16 @@ 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 ValueT = TypeVar("ValueT") -class TemperatureProtocol: +class TemperatureProtocol(Tracer): def __init__(self, connection: IPConnection, suffix: str = ""): + super().__init__() self._connection = connection self._suffix = suffix @@ -29,12 +31,14 @@ async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): await self._connection.send_command(f"{command}\r\n") - async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + 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.trace("Query for attribute", query=query, response=value) + self.log_event("Query for attribute", topic=topic, query=query, response=value) return value @@ -67,28 +71,28 @@ def __init__(self, index: int, connection: IPConnection) -> None: self.voltage = AttrR(Float()) async def _get_start(self) -> int: - return await self._protocol.send_query("S", int) + return await self._protocol.send_query("S", int, topic=self.start) 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) + 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)) + 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) + 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) + return await self._protocol.send_query("A", float, topic=self.actual) class TemperatureController(Controller): @@ -115,13 +119,13 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self.add_sub_controller(f"R{index}", controller) async def _get_device_id(self) -> str: - return await self._protocol.send_query("ID", 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) + 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) + 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) 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..fcbe7922e 100644 --- a/docs/tutorials/static-drivers.md +++ b/docs/tutorials/static-drivers.md @@ -82,7 +82,7 @@ In [1]: controller.device_id Out[1]: AttrR(String()) -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` +The `poll_period` argument tells the base class how often to call the getter. It +defaults to calling once at start up when a getter is given. ::: ::::{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.