diff --git a/pyproject.toml b/pyproject.toml index ff870e073..b83fa4ad8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "pydantic", "pvi~=0.10.0", "pytango", - "softioc", + "softioc>=4.5.0", ] dynamic = ["version"] license.file = "LICENSE" @@ -42,7 +42,7 @@ dev = [ "tox-direct", "types-mock", "aioca", - "p4p", + "p4p>=4.2.0", ] [project.scripts] @@ -61,7 +61,7 @@ version_file = "src/fastcs/_version.py" [tool.pyright] typeCheckingMode = "standard" -reportMissingImports = false # Ignore missing stubs in imported modules +reportMissingImports = false # Ignore missing stubs in imported modules [tool.pytest.ini_options] # Run pytest with all our checkers, and don't spam us with massive tracebacks on error diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 1bcb56382..de33dbda7 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from enum import Enum from typing import Any, Generic, Protocol, runtime_checkable @@ -26,7 +27,8 @@ async def put(self, controller: Any, attr: AttrW, value: Any) -> None: class Updater(Protocol): """Protocol for updating the cached readback value of an ``Attribute``.""" - update_period: float + # If update period is None then the attribute will not be updated as a task. + update_period: float | None = None async def update(self, controller: Any, attr: AttrR) -> None: pass @@ -52,6 +54,7 @@ def __init__( group: str | None = None, handler: Any = None, allowed_values: list[T] | None = None, + description: str | None = None, ) -> None: assert ( datatype.dtype in ATTRIBUTE_TYPES @@ -61,6 +64,11 @@ def __init__( self._group = group self.enabled = True self._allowed_values: list[T] | None = allowed_values + self.description = description + + # A callback to use when setting the datatype to a different value, for example + # changing the units on an int. This should be implemented in the backend. + self._update_datatype_callbacks: list[Callable[[DataType[T]], None]] = [] @property def datatype(self) -> DataType[T]: @@ -82,6 +90,20 @@ def group(self) -> str | None: def allowed_values(self) -> list[T] | None: return self._allowed_values + def add_update_datatype_callback( + self, callback: Callable[[DataType[T]], None] + ) -> None: + self._update_datatype_callbacks.append(callback) + + def update_datatype(self, datatype: DataType[T]) -> None: + if not isinstance(self._datatype, type(datatype)): + raise ValueError( + f"Attribute datatype must be of type {type(self._datatype)}" + ) + self._datatype = datatype + for callback in self._update_datatype_callbacks: + callback(datatype) + class AttrR(Attribute[T]): """A read-only ``Attribute``.""" @@ -92,7 +114,9 @@ def __init__( access_mode=AttrMode.READ, group: str | None = None, handler: Updater | None = None, + initial_value: T | None = None, allowed_values: list[T] | None = None, + description: str | None = None, ) -> None: super().__init__( datatype, # type: ignore @@ -100,8 +124,11 @@ def __init__( group, handler, allowed_values=allowed_values, # type: ignore + description=description, + ) + self._value: T = ( + datatype.initial_value if initial_value is None else initial_value ) - self._value: T = datatype.dtype() self._update_callback: AttrCallback[T] | None = None self._updater = handler @@ -109,7 +136,7 @@ def get(self) -> T: return self._value async def set(self, value: T) -> None: - self._value = self._datatype.dtype(value) + self._value = self._datatype.cast(value) if self._update_callback is not None: await self._update_callback(self._value) @@ -132,6 +159,7 @@ def __init__( group: str | None = None, handler: Sender | None = None, allowed_values: list[T] | None = None, + description: str | None = None, ) -> None: super().__init__( datatype, # type: ignore @@ -139,6 +167,7 @@ def __init__( group, handler, allowed_values=allowed_values, # type: ignore + description=description, ) self._process_callback: AttrCallback[T] | None = None self._write_display_callback: AttrCallback[T] | None = None @@ -150,11 +179,11 @@ async def process(self, value: T) -> None: async def process_without_display_update(self, value: T) -> None: if self._process_callback is not None: - await self._process_callback(self._datatype.dtype(value)) + await self._process_callback(self._datatype.cast(value)) async def update_display_without_process(self, value: T) -> None: if self._write_display_callback is not None: - await self._write_display_callback(self._datatype.dtype(value)) + await self._write_display_callback(self._datatype.cast(value)) def set_process_callback(self, callback: AttrCallback[T] | None) -> None: self._process_callback = callback @@ -170,7 +199,7 @@ def sender(self) -> Sender | None: return self._sender -class AttrRW(AttrW[T], AttrR[T]): +class AttrRW(AttrR[T], AttrW[T]): """A read-write ``Attribute``.""" def __init__( @@ -179,14 +208,18 @@ def __init__( access_mode=AttrMode.READ_WRITE, group: str | None = None, handler: Handler | None = None, + initial_value: T | None = None, allowed_values: list[T] | None = None, + description: str | None = None, ) -> None: super().__init__( datatype, # type: ignore access_mode, - group, - handler, - allowed_values, # type: ignore + group=group, + handler=handler, + initial_value=initial_value, + allowed_values=allowed_values, # type: ignore + description=description, ) async def process(self, value: T) -> None: diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 10f7bc3e8..d20075e27 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -137,7 +137,8 @@ def _add_attribute_updater_tasks( callback = _create_updater_callback( attribute, single_mapping.controller ) - scan_dict[update_period].append(callback) + if update_period is not None: + scan_dict[update_period].append(callback) def _create_updater_callback(attribute, controller): diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index 7aa0e8924..0f1327e54 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -1,5 +1,5 @@ from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import asdict, dataclass from types import MethodType from typing import Any, Literal @@ -15,7 +15,7 @@ enum_value_to_index, ) from fastcs.controller import BaseController -from fastcs.datatypes import Bool, Float, Int, String, T +from fastcs.datatypes import Bool, DataType, Float, Int, String, T from fastcs.exceptions import FastCSException from fastcs.mapping import Mapping @@ -27,6 +27,26 @@ class EpicsIOCOptions: terminal: bool = True +DATATYPE_NAME_TO_RECORD_FIELD = { + "prec": "PREC", + "units": "EGU", + "min": "DRVL", + "max": "DRVH", + "min_alarm": "LOPR", + "max_alarm": "HOPR", + "znam": "ZNAM", + "onam": "ONAM", +} + + +def datatype_to_epics_fields(datatype: DataType) -> dict[str, Any]: + return { + DATATYPE_NAME_TO_RECORD_FIELD[field]: value + for field, value in asdict(datatype).items() + if field in DATATYPE_NAME_TO_RECORD_FIELD + } + + class EpicsIOC: def __init__(self, pv_prefix: str, mapping: Mapping): _add_pvi_info(f"{pv_prefix}:PVI") @@ -172,27 +192,50 @@ async def async_record_set(value: T): def _get_input_record(pv: str, attribute: AttrR) -> RecordWrapper: + attribute_fields = {} + if attribute.description is not None: + attribute_fields.update({"DESC": attribute.description}) + if attr_is_enum(attribute): assert attribute.allowed_values is not None and all( isinstance(v, str) for v in attribute.allowed_values ) state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False)) - return builder.mbbIn(pv, **state_keys) + return builder.mbbIn(pv, **state_keys, **attribute_fields) match attribute.datatype: - case Bool(znam, onam): - return builder.boolIn(pv, ZNAM=znam, ONAM=onam) + case Bool(): + record = builder.boolIn( + pv, **datatype_to_epics_fields(attribute.datatype), **attribute_fields + ) case Int(): - return builder.longIn(pv) - case Float(prec): - return builder.aIn(pv, PREC=prec) + record = builder.longIn( + pv, + **datatype_to_epics_fields(attribute.datatype), + **attribute_fields, + ) + case Float(): + record = builder.aIn( + pv, + **datatype_to_epics_fields(attribute.datatype), + **attribute_fields, + ) case String(): - return builder.longStringIn(pv) + record = builder.longStringIn( + pv, **datatype_to_epics_fields(attribute.datatype), **attribute_fields + ) case _: raise FastCSException( f"Unsupported type {type(attribute.datatype)}: {attribute.datatype}" ) + def datatype_updater(datatype: DataType): + for name, value in datatype_to_epics_fields(datatype).items(): + record.set_field(name, value) + + attribute.add_update_datatype_callback(datatype_updater) + return record + def _create_and_link_write_pv( pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW[T] @@ -224,33 +267,62 @@ async def async_write_display(value: T): def _get_output_record(pv: str, attribute: AttrW, on_update: Callable) -> Any: + attribute_fields = {} + if attribute.description is not None: + attribute_fields.update({"DESC": attribute.description}) if attr_is_enum(attribute): assert attribute.allowed_values is not None and all( isinstance(v, str) for v in attribute.allowed_values ) state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False)) - return builder.mbbOut(pv, always_update=True, on_update=on_update, **state_keys) + return builder.mbbOut( + pv, + always_update=True, + on_update=on_update, + **state_keys, + **attribute_fields, + ) match attribute.datatype: - case Bool(znam, onam): - return builder.boolOut( + case Bool(): + record = builder.boolOut( pv, - ZNAM=znam, - ONAM=onam, + **datatype_to_epics_fields(attribute.datatype), always_update=True, on_update=on_update, ) case Int(): - return builder.longOut(pv, always_update=True, on_update=on_update) - case Float(prec): - return builder.aOut(pv, always_update=True, on_update=on_update, PREC=prec) + record = builder.longOut( + pv, + always_update=True, + on_update=on_update, + **datatype_to_epics_fields(attribute.datatype), + **attribute_fields, + ) + case Float(): + record = builder.aOut( + pv, + always_update=True, + on_update=on_update, + **datatype_to_epics_fields(attribute.datatype), + **attribute_fields, + ) case String(): - return builder.longStringOut(pv, always_update=True, on_update=on_update) + record = builder.longStringOut( + pv, always_update=True, on_update=on_update, **attribute_fields + ) case _: raise FastCSException( f"Unsupported type {type(attribute.datatype)}: {attribute.datatype}" ) + def datatype_updater(datatype: DataType): + for name, value in datatype_to_epics_fields(datatype).items(): + record.set_field(name, value) + + attribute.add_update_datatype_callback(datatype_updater) + return record + def _create_and_link_command_pvs(pv_prefix: str, mapping: Mapping) -> None: for single_mapping in mapping.get_controller_mappings(): diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py index ccb361d07..e3fd843ba 100644 --- a/src/fastcs/datatypes.py +++ b/src/fastcs/datatypes.py @@ -3,7 +3,7 @@ from abc import abstractmethod from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar T = TypeVar("T", int, float, bool, str) ATTRIBUTE_TYPES: tuple[type] = T.__constraints__ # type: ignore @@ -12,6 +12,7 @@ AttrCallback = Callable[[T], Awaitable[None]] +@dataclass(frozen=True) # So that we can type hint with dataclass methods class DataType(Generic[T]): """Generic datatype mapping to a python type, with additional metadata.""" @@ -20,9 +21,40 @@ class DataType(Generic[T]): def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars pass + @abstractmethod + def cast(self, value: T) -> Any: + """Cast a value to a more primative datatype for `Attribute` push. + + Also validate it against fields in the datatype. + """ + pass + + @property + def initial_value(self) -> T: + return self.dtype() + + +T_Numerical = TypeVar("T_Numerical", int, float) + @dataclass(frozen=True) -class Int(DataType[int]): +class _Numerical(DataType[T_Numerical]): + units: str | None = None + min: int | None = None + max: int | None = None + min_alarm: int | None = None + max_alarm: int | None = None + + def cast(self, value: T_Numerical) -> T_Numerical: + if self.min is not None and value < self.min: + raise ValueError(f"Value {value} is less than minimum {self.min}") + if self.max is not None and value > self.max: + raise ValueError(f"Value {value} is greater than maximum {self.max}") + return value + + +@dataclass(frozen=True) +class Int(_Numerical[int]): """`DataType` mapping to builtin ``int``.""" @property @@ -31,7 +63,7 @@ def dtype(self) -> type[int]: @dataclass(frozen=True) -class Float(DataType[float]): +class Float(_Numerical[float]): """`DataType` mapping to builtin ``float``.""" prec: int = 2 @@ -52,6 +84,9 @@ class Bool(DataType[bool]): def dtype(self) -> type[bool]: return bool + def cast(self, value: bool) -> bool: + return value + @dataclass(frozen=True) class String(DataType[str]): @@ -60,3 +95,6 @@ class String(DataType[str]): @property def dtype(self) -> type[str]: return str + + def cast(self, value: str) -> str: + return value diff --git a/tests/backends/epics/test_ioc.py b/tests/backends/epics/test_ioc.py index c1a5b3218..721bb2952 100644 --- a/tests/backends/epics/test_ioc.py +++ b/tests/backends/epics/test_ioc.py @@ -211,6 +211,15 @@ def test_get_output_record_raises(mocker: MockerFixture): _get_output_record("PV", mocker.MagicMock(), on_update=mocker.MagicMock()) +DEFAULT_SCALAR_FIELD_ARGS = { + "EGU": None, + "DRVL": None, + "DRVH": None, + "LOPR": None, + "HOPR": None, +} + + def test_ioc(mocker: MockerFixture, mapping: Mapping): builder = mocker.patch("fastcs.backends.epics.ioc.builder") add_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_pvi_info") @@ -222,15 +231,26 @@ def test_ioc(mocker: MockerFixture, mapping: Mapping): # Check records are created builder.boolIn.assert_called_once_with(f"{DEVICE}:ReadBool", ZNAM="OFF", ONAM="ON") - builder.longIn.assert_any_call(f"{DEVICE}:ReadInt") - builder.aIn.assert_called_once_with(f"{DEVICE}:ReadWriteFloat_RBV", PREC=2) + builder.longIn.assert_any_call(f"{DEVICE}:ReadInt", **DEFAULT_SCALAR_FIELD_ARGS) + builder.aIn.assert_called_once_with( + f"{DEVICE}:ReadWriteFloat_RBV", PREC=2, **DEFAULT_SCALAR_FIELD_ARGS + ) builder.aOut.assert_any_call( - f"{DEVICE}:ReadWriteFloat", always_update=True, on_update=mocker.ANY, PREC=2 + f"{DEVICE}:ReadWriteFloat", + always_update=True, + on_update=mocker.ANY, + PREC=2, + **DEFAULT_SCALAR_FIELD_ARGS, + ) + builder.longIn.assert_any_call(f"{DEVICE}:BigEnum", **DEFAULT_SCALAR_FIELD_ARGS) + builder.longIn.assert_any_call( + f"{DEVICE}:ReadWriteInt_RBV", **DEFAULT_SCALAR_FIELD_ARGS ) - builder.longIn.assert_any_call(f"{DEVICE}:BigEnum") - builder.longIn.assert_any_call(f"{DEVICE}:ReadWriteInt_RBV") builder.longOut.assert_called_with( - f"{DEVICE}:ReadWriteInt", always_update=True, on_update=mocker.ANY + f"{DEVICE}:ReadWriteInt", + always_update=True, + on_update=mocker.ANY, + **DEFAULT_SCALAR_FIELD_ARGS, ) builder.mbbIn.assert_called_once_with( f"{DEVICE}:StringEnum_RBV", ZRST="red", ONST="green", TWST="blue" @@ -390,9 +410,10 @@ def test_long_pv_names_discarded(mocker: MockerFixture): f"{DEVICE}:{short_pv_name}", always_update=True, on_update=mocker.ANY, + **DEFAULT_SCALAR_FIELD_ARGS, ) builder.longIn.assert_called_once_with( - f"{DEVICE}:{short_pv_name}_RBV", + f"{DEVICE}:{short_pv_name}_RBV", **DEFAULT_SCALAR_FIELD_ARGS ) long_pv_name = long_attr_name.title().replace("_", "") @@ -438,3 +459,39 @@ def test_long_pv_names_discarded(mocker: MockerFixture): always_update=True, on_update=mocker.ANY, ) + + +def test_update_datatype(mocker: MockerFixture): + builder = mocker.patch("fastcs.backends.epics.ioc.builder") + + pv_name = f"{DEVICE}:Attr" + + attr_r = AttrR(Int()) + record_r = _get_input_record(pv_name, attr_r) + + builder.longIn.assert_called_once_with(pv_name, **DEFAULT_SCALAR_FIELD_ARGS) + record_r.set_field.assert_not_called() + attr_r.update_datatype(Int(units="m", min=-3)) + record_r.set_field.assert_any_call("EGU", "m") + record_r.set_field.assert_any_call("DRVL", -3) + + with pytest.raises( + ValueError, + match="Attribute datatype must be of type ", + ): + attr_r.update_datatype(String()) # type: ignore + + attr_w = AttrW(Int()) + record_w = _get_output_record(pv_name, attr_w, on_update=mocker.ANY) + + builder.longIn.assert_called_once_with(pv_name, **DEFAULT_SCALAR_FIELD_ARGS) + record_w.set_field.assert_not_called() + attr_w.update_datatype(Int(units="m", min=-3)) + record_w.set_field.assert_any_call("EGU", "m") + record_w.set_field.assert_any_call("DRVL", -3) + + with pytest.raises( + ValueError, + match="Attribute datatype must be of type ", + ): + attr_w.update_datatype(String()) # type: ignore