diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 7df4cd83..208fec97 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -7,14 +7,15 @@ from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.datatypes import DataType, DType_T +from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform +from fastcs.exceptions import FastCSError from fastcs.logging import bind_logger from fastcs.methods import Command from fastcs.tracer import Tracer from fastcs.transports.controller_api import ControllerAPI from fastcs.transports.epics import EpicsIOCOptions from fastcs.transports.epics.ca.util import ( - builder_callable_from_attribute, + MBB_MAX_CHOICES, cast_from_epics_type, cast_to_epics_type, record_metadata_from_attribute, @@ -177,36 +178,124 @@ async def async_record_set(value: DType_T): record.set(cast_to_epics_type(attribute.datatype, value)) - record = _make_record(pv, attribute) + record = _make_in_record(pv, attribute) _add_attr_pvi_info(record, pv_prefix, attr_name, "r") attribute.add_on_update_callback(async_record_set) -def _make_record( +def _make_in_record( pv: str, attribute: AttrR | AttrW | AttrRW, - on_update: Callable | None = None, - out_record: bool = False, ) -> RecordWrapper: - builder_callable = builder_callable_from_attribute(attribute, on_update is None) + datatype_record_metadata = record_metadata_from_datatype(attribute.datatype) + attribute_record_metadata = record_metadata_from_attribute(attribute) + + match attribute.datatype: + case Bool(): + record = builder.boolIn( + pv, **datatype_record_metadata, **attribute_record_metadata + ) + case Int(): + record = builder.longIn( + pv, **datatype_record_metadata, **attribute_record_metadata + ) + case Float(): + record = builder.aIn( + pv, **datatype_record_metadata, **attribute_record_metadata + ) + case String(): + record = builder.longStringIn( + pv, **datatype_record_metadata, **attribute_record_metadata + ) + case Enum(): + if len(attribute.datatype.members) > MBB_MAX_CHOICES: + record = builder.longStringIn( + pv, + **datatype_record_metadata, + **attribute_record_metadata, + ) + else: + record = builder.mbbIn( + pv, + **datatype_record_metadata, + **attribute_record_metadata, + ) + case Waveform(): + record = builder.WaveformIn( + pv, **datatype_record_metadata, **attribute_record_metadata + ) + case _: + raise FastCSError( + f"EPICS unsupported datatype on {attribute}: {attribute.datatype}" + ) + + def datatype_updater(datatype: DataType): + for name, value in record_metadata_from_datatype(datatype).items(): + record.set_field(name, value) + + attribute.add_update_datatype_callback(datatype_updater) + return record + + +def _make_out_record( + pv: str, + attribute: AttrR | AttrW | AttrRW, + on_update: Callable, +) -> RecordWrapper: datatype_record_metadata = record_metadata_from_datatype( - attribute.datatype, out_record + attribute.datatype, out_record=True ) attribute_record_metadata = record_metadata_from_attribute(attribute) - update = ( - {"on_update": on_update, "always_update": True, "blocking": True} - if on_update - else {} - ) + update = {"on_update": on_update, "always_update": True, "blocking": True} + + match attribute.datatype: + case Bool(): + record = builder.boolOut( + pv, **update, **datatype_record_metadata, **attribute_record_metadata + ) + case Int(): + record = builder.longOut( + pv, **update, **datatype_record_metadata, **attribute_record_metadata + ) + case Float(): + record = builder.aOut( + pv, **update, **datatype_record_metadata, **attribute_record_metadata + ) + case String(): + record = builder.longStringOut( + pv, **update, **datatype_record_metadata, **attribute_record_metadata + ) + case Enum(): + if len(attribute.datatype.members) > MBB_MAX_CHOICES: + record = builder.longStringOut( + pv, + **update, + **datatype_record_metadata, + **attribute_record_metadata, + ) - record = builder_callable( - pv, **update, **datatype_record_metadata, **attribute_record_metadata - ) + else: + record = builder.mbbOut( + pv, + **update, + **datatype_record_metadata, + **attribute_record_metadata, + ) + case Waveform(): + record = builder.WaveformOut( + pv, **update, **datatype_record_metadata, **attribute_record_metadata + ) + case _: + raise FastCSError( + f"EPICS unsupported datatype on {attribute}: {attribute.datatype}" + ) def datatype_updater(datatype: DataType): - for name, value in record_metadata_from_datatype(datatype, out_record).items(): + for name, value in record_metadata_from_datatype( + datatype, out_record=True + ).items(): record.set_field(name, value) attribute.add_update_datatype_callback(datatype_updater) @@ -230,7 +319,7 @@ async def set_setpoint_without_process(value: DType_T): record.set(cast_to_epics_type(attribute.datatype, value), process=False) - record = _make_record(pv, attribute, on_update=on_update, out_record=True) + record = _make_out_record(pv, attribute, on_update=on_update) _add_attr_pvi_info(record, pv_prefix, attr_name, "w") diff --git a/src/fastcs/transports/epics/ca/util.py b/src/fastcs/transports/epics/ca/util.py index 2f19d250..27464b84 100644 --- a/src/fastcs/transports/epics/ca/util.py +++ b/src/fastcs/transports/epics/ca/util.py @@ -2,12 +2,9 @@ from dataclasses import asdict from typing import Any -from softioc import builder - -from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.attributes import Attribute, AttrR, AttrW from fastcs.datatypes import Bool, DType_T, Enum, Float, Int, String, Waveform from fastcs.datatypes.datatype import DataType -from fastcs.exceptions import FastCSError _MBB_FIELD_PREFIXES = ( "ZR", @@ -154,29 +151,3 @@ def cast_to_epics_type(datatype: DataType[DType_T], value: DType_T) -> Any: return value case _: raise ValueError(f"Unsupported datatype {datatype}") - - -def builder_callable_from_attribute( - attribute: AttrR | AttrW | AttrRW, make_in_record: bool -): - """Returns a callable to make the softioc record from an attribute instance.""" - match attribute.datatype: - case Bool(): - return builder.boolIn if make_in_record else builder.boolOut - case Int(): - return builder.longIn if make_in_record else builder.longOut - case Float(): - return builder.aIn if make_in_record else builder.aOut - case String(): - return builder.longStringIn if make_in_record else builder.longStringOut - case Enum(): - if len(attribute.datatype.members) > MBB_MAX_CHOICES: - return builder.longStringIn if make_in_record else builder.longStringOut - else: - return builder.mbbIn if make_in_record else builder.mbbOut - case Waveform(): - return builder.WaveformIn if make_in_record else builder.WaveformOut - case _: - raise FastCSError( - f"EPICS unsupported datatype on {attribute}: {attribute.datatype}" - ) diff --git a/tests/transports/epics/ca/test_ca_util.py b/tests/transports/epics/ca/test_ca_util.py index 2993b1e6..8488c41d 100644 --- a/tests/transports/epics/ca/test_ca_util.py +++ b/tests/transports/epics/ca/test_ca_util.py @@ -1,12 +1,9 @@ import enum import pytest -from softioc import builder -from fastcs.attributes import AttrRW from fastcs.datatypes import Bool, Enum, Float, Int, String from fastcs.transports.epics.ca.util import ( - builder_callable_from_attribute, cast_from_epics_type, cast_to_epics_type, record_metadata_from_datatype, @@ -137,21 +134,6 @@ def test_cast_from_epics_validations(datatype, input): cast_from_epics_type(datatype, input) -@pytest.mark.parametrize( - "datatype,in_record,out_record", - [ - (Enum(ShortEnum), builder.mbbIn, builder.mbbOut), - # long enums use string even if all values are ints - (Enum(LongEnum), builder.longStringIn, builder.longStringOut), - (Enum(LongMixedEnum), builder.longStringIn, builder.longStringOut), - ], -) -def test_builder_callable_enum_types(datatype, in_record, out_record): - attr = AttrRW(datatype) - assert builder_callable_from_attribute(attr, False) == out_record - assert builder_callable_from_attribute(attr, True) == in_record - - def test_drive_metadata_from_datatype(): dtype = Float(units="mm", min=-10.0, max=10.0, min_alarm=-5, max_alarm=5, prec=3) out_arguments = record_metadata_from_datatype(dtype, True) diff --git a/tests/transports/epics/ca/test_initial_value.py b/tests/transports/epics/ca/test_initial_value.py index e340b4c4..727cb0d7 100644 --- a/tests/transports/epics/ca/test_initial_value.py +++ b/tests/transports/epics/ca/test_initial_value.py @@ -59,16 +59,18 @@ async def test_initial_values_set_in_ca(mocker): loop, ) - record_spy = mocker.spy(ca_ioc, "_make_record") + record_spy = mocker.spy(ca_ioc, "_make_in_record") + record_spy_out = mocker.spy(ca_ioc, "_make_out_record") task = asyncio.create_task(fastcs.serve(interactive=False)) try: async with asyncio.timeout(3): - while not record_spy.spy_return_list: + while not record_spy.spy_return_list or not record_spy_out.spy_return_list: await asyncio.sleep(0) initial_values = { - wrapper.name: wrapper.get() for wrapper in record_spy.spy_return_list + wrapper.name: wrapper.get() + for wrapper in record_spy.spy_return_list + record_spy_out.spy_return_list } for name, value in { "SOFTIOC_INITIAL_DEVICE:Bool": 1, diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py index d9eb3b1e..1d0bbe2c 100644 --- a/tests/transports/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -27,7 +27,8 @@ _add_sub_controller_pvi_info, _create_and_link_read_pv, _create_and_link_write_pv, - _make_record, + _make_in_record, + _make_out_record, ) from fastcs.transports.epics.ca.util import ( record_metadata_from_attribute, @@ -46,7 +47,7 @@ class OnOffStates(enum.IntEnum): @pytest.mark.asyncio async def test_create_and_link_read_pv(mocker: MockerFixture): - make_record = mocker.patch("fastcs.transports.epics.ca.ioc._make_record") + make_record = mocker.patch("fastcs.transports.epics.ca.ioc._make_in_record") add_attr_pvi_info = mocker.patch( "fastcs.transports.epics.ca.ioc._add_attr_pvi_info" ) @@ -91,10 +92,10 @@ def test_make_input_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.transports.epics.ca.util.builder") + builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") pv = "PV" - _make_record(pv, attribute) + _make_in_record(pv, attribute) kwargs.update(record_metadata_from_datatype(attribute.datatype)) kwargs.update(record_metadata_from_attribute(attribute)) @@ -105,14 +106,15 @@ def test_make_input_record( def test_make_record_raises(mocker: MockerFixture): + mocker.patch("fastcs.transports.epics.ca.ioc.record_metadata_from_datatype") # Pass a mock as attribute to provoke the fallback case matching on datatype with pytest.raises(FastCSError): - _make_record("PV", mocker.MagicMock()) + _make_in_record("PV", mocker.MagicMock()) @pytest.mark.asyncio async def test_create_and_link_write_pv(mocker: MockerFixture): - make_record = mocker.patch("fastcs.transports.epics.ca.ioc._make_record") + make_record = mocker.patch("fastcs.transports.epics.ca.ioc._make_out_record") add_attr_pvi_info = mocker.patch( "fastcs.transports.epics.ca.ioc._add_attr_pvi_info" ) @@ -124,9 +126,7 @@ async def test_create_and_link_write_pv(mocker: MockerFixture): _create_and_link_write_pv("PREFIX", "PV", "attr", attribute) - make_record.assert_called_once_with( - "PREFIX:PV", attribute, on_update=mocker.ANY, out_record=True - ) + 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 @@ -179,11 +179,11 @@ def test_make_output_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.transports.epics.ca.util.builder") + builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") update = mocker.MagicMock() pv = "PV" - _make_record(pv, attribute, on_update=update, out_record=True) + _make_out_record(pv, attribute, on_update=update) kwargs.update(record_metadata_from_datatype(attribute.datatype, out_record=True)) kwargs.update(record_metadata_from_attribute(attribute)) @@ -196,20 +196,29 @@ def test_make_output_record( def test_long_enum_validator(mocker: MockerFixture): - builder = mocker.patch("fastcs.transports.epics.ca.util.builder") + builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") update = mocker.MagicMock() attribute = AttrRW(Enum(LongEnum)) pv = "PV" - record = _make_record(pv, attribute, on_update=update, out_record=True) + record = _make_out_record(pv, attribute, on_update=update) validator = builder.longStringOut.call_args.kwargs["validate"] assert validator(record, "THIS") # value is one of the Enum names assert not validator(record, "an invalid string value") +def test_long_enum_in_creation(mocker: MockerFixture): + builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") + attribute = AttrR(Enum(LongEnum)) + pv = "PV" + _make_in_record(pv, attribute) + assert builder.longStringIn.call_args.kwargs["initial_value"] == "THIS" + + def test_get_output_record_raises(mocker: MockerFixture): + mocker.patch("fastcs.transports.epics.ca.ioc.record_metadata_from_datatype") # Pass a mock as attribute to provoke the fallback case matching on datatype with pytest.raises(FastCSError): - _make_record("PV", mocker.MagicMock(), on_update=mocker.MagicMock()) + _make_out_record("PV", mocker.MagicMock(), on_update=mocker.MagicMock()) class EpicsController(MyTestController): @@ -230,7 +239,6 @@ def epics_controller_api(class_mocker: MockerFixture): def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): ioc_builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") - builder = mocker.patch("fastcs.transports.epics.ca.util.builder") add_pvi_info = mocker.patch("fastcs.transports.epics.ca.ioc._add_pvi_info") add_sub_controller_pvi_info = mocker.patch( "fastcs.transports.epics.ca.ioc._add_sub_controller_pvi_info" @@ -239,21 +247,21 @@ def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): EpicsCAIOC(DEVICE, epics_controller_api) # Check records are created - builder.boolIn.assert_called_once_with( + ioc_builder.boolIn.assert_called_once_with( f"{DEVICE}:ReadBool", **record_metadata_from_attribute(epics_controller_api.attributes["read_bool"]), **record_metadata_from_datatype( epics_controller_api.attributes["read_bool"].datatype ), ) - builder.longIn.assert_any_call( + ioc_builder.longIn.assert_any_call( f"{DEVICE}:ReadInt", **record_metadata_from_attribute(epics_controller_api.attributes["read_int"]), **record_metadata_from_datatype( epics_controller_api.attributes["read_int"].datatype ), ) - builder.aIn.assert_called_once_with( + ioc_builder.aIn.assert_called_once_with( f"{DEVICE}:ReadWriteFloat_RBV", **record_metadata_from_attribute( epics_controller_api.attributes["read_write_float"] @@ -262,7 +270,7 @@ def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): epics_controller_api.attributes["read_write_float"].datatype ), ) - builder.aOut.assert_any_call( + ioc_builder.aOut.assert_any_call( f"{DEVICE}:ReadWriteFloat", always_update=True, blocking=True, @@ -275,7 +283,7 @@ def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): out_record=True, ), ) - builder.longIn.assert_any_call( + ioc_builder.longIn.assert_any_call( f"{DEVICE}:ReadWriteInt_RBV", **record_metadata_from_attribute( epics_controller_api.attributes["read_write_int"] @@ -284,7 +292,7 @@ def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): epics_controller_api.attributes["read_write_int"].datatype ), ) - builder.longOut.assert_called_with( + ioc_builder.longOut.assert_called_with( f"{DEVICE}:ReadWriteInt", always_update=True, blocking=True, @@ -296,14 +304,14 @@ def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): epics_controller_api.attributes["read_write_int"].datatype, out_record=True ), ) - builder.mbbIn.assert_called_once_with( + ioc_builder.mbbIn.assert_called_once_with( f"{DEVICE}:Enum_RBV", **record_metadata_from_attribute(epics_controller_api.attributes["enum"]), **record_metadata_from_datatype( epics_controller_api.attributes["enum"].datatype ), ) - builder.mbbOut.assert_called_once_with( + ioc_builder.mbbOut.assert_called_once_with( f"{DEVICE}:Enum", always_update=True, blocking=True, @@ -313,7 +321,7 @@ def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): epics_controller_api.attributes["enum"].datatype, out_record=True ), ) - builder.boolOut.assert_called_once_with( + ioc_builder.boolOut.assert_called_once_with( f"{DEVICE}:WriteBool", always_update=True, blocking=True, @@ -450,7 +458,6 @@ class ControllerLongNames(Controller): def test_long_pv_names_discarded(mocker: MockerFixture): ioc_builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") - builder = mocker.patch("fastcs.transports.epics.ca.util.builder") long_name_controller_api = AssertableControllerAPI(ControllerLongNames(), mocker) long_attr_name = "attr_r_with_reallyreallyreallyreallyreallyreallyreally_long_name" long_rw_name = "attr_rw_with_a_reallyreally_long_name_that_is_too_long_for_RBV" @@ -461,7 +468,7 @@ def test_long_pv_names_discarded(mocker: MockerFixture): assert not long_name_controller_api.attributes[long_attr_name].enabled short_pv_name = "attr_rw_short_name".title().replace("_", "") - builder.longOut.assert_called_once_with( + ioc_builder.longOut.assert_called_once_with( f"{DEVICE}:{short_pv_name}", always_update=True, blocking=True, @@ -474,7 +481,7 @@ def test_long_pv_names_discarded(mocker: MockerFixture): long_name_controller_api.attributes["attr_rw_short_name"] ), ) - builder.longIn.assert_called_once_with( + ioc_builder.longIn.assert_called_once_with( f"{DEVICE}:{short_pv_name}_RBV", **record_metadata_from_datatype( long_name_controller_api.attributes[ @@ -490,7 +497,7 @@ def test_long_pv_names_discarded(mocker: MockerFixture): long_pv_name = long_attr_name.title().replace("_", "") with pytest.raises(AssertionError): - builder.longIn.assert_called_once_with(f"{DEVICE}:{long_pv_name}") + ioc_builder.longIn.assert_called_once_with(f"{DEVICE}:{long_pv_name}") long_rw_pv_name = long_rw_name.title().replace("_", "") # neither the readback nor setpoint PV gets made if the full pv name with _RBV @@ -502,14 +509,14 @@ def test_long_pv_names_discarded(mocker: MockerFixture): ) with pytest.raises(AssertionError): - builder.longOut.assert_called_once_with( + ioc_builder.longOut.assert_called_once_with( f"{DEVICE}:{long_rw_pv_name}", always_update=True, blocking=True, on_update=mocker.ANY, ) with pytest.raises(AssertionError): - builder.longIn.assert_called_once_with(f"{DEVICE}:{long_rw_pv_name}_RBV") + ioc_builder.longIn.assert_called_once_with(f"{DEVICE}:{long_rw_pv_name}_RBV") assert long_name_controller_api.command_methods["command_short_name"].enabled long_command_name = ( @@ -528,7 +535,7 @@ def test_long_pv_names_discarded(mocker: MockerFixture): ) with pytest.raises(AssertionError): long_command_pv_name = long_command_name.title().replace("_", "") - builder.aOut.assert_called_once_with( + ioc_builder.aOut.assert_called_once_with( f"{DEVICE}:{long_command_pv_name}", initial_value=0, always_update=True, @@ -537,12 +544,12 @@ def test_long_pv_names_discarded(mocker: MockerFixture): def test_update_datatype(mocker: MockerFixture): - builder = mocker.patch("fastcs.transports.epics.ca.util.builder") + builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") pv_name = f"{DEVICE}:Attr" attr_r = AttrR(Int()) - record_r = _make_record(pv_name, attr_r) + record_r = _make_in_record(pv_name, attr_r) builder.longIn.assert_called_once_with( pv_name, @@ -561,7 +568,7 @@ def test_update_datatype(mocker: MockerFixture): attr_r.update_datatype(String()) # type: ignore attr_w = AttrW(Int()) - record_w = _make_record(pv_name, attr_w, on_update=mocker.ANY, out_record=True) + record_w = _make_out_record(pv_name, attr_w, on_update=mocker.ANY) builder.longIn.assert_called_once_with( pv_name,