diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 1bcb5638..742e11c5 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -26,7 +26,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 async def update(self, controller: Any, attr: AttrR) -> None: pass diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 10f7bc3e..dde3df7b 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -134,6 +134,8 @@ def _add_attribute_updater_tasks( for attribute in single_mapping.attributes.values(): match attribute: case AttrR(updater=Updater(update_period=update_period)) as attribute: + if update_period is None: + continue callback = _create_updater_callback( attribute, single_mapping.controller ) diff --git a/src/fastcs/backends/epics/backend.py b/src/fastcs/backends/epics/backend.py index 20373183..b34eff8f 100644 --- a/src/fastcs/backends/epics/backend.py +++ b/src/fastcs/backends/epics/backend.py @@ -7,17 +7,26 @@ class EpicsBackend(Backend): - def __init__(self, controller: Controller, pv_prefix: str = "MY-DEVICE-PREFIX"): + def __init__( + self, + controller: Controller, + pv_prefix: str = "MY-DEVICE-PREFIX", + ioc_options: EpicsIOCOptions | None = None, + ): super().__init__(controller) self._pv_prefix = pv_prefix - self._ioc = EpicsIOC(pv_prefix, self._mapping) + self.ioc_options = ioc_options or EpicsIOCOptions() + self._ioc = EpicsIOC(pv_prefix, self._mapping, options=ioc_options) - def create_docs(self, options: EpicsDocsOptions | None = None) -> None: - EpicsDocs(self._mapping).create_docs(options) + def create_docs(self, docs_options: EpicsDocsOptions | None = None) -> None: + EpicsDocs(self._mapping).create_docs(docs_options) - def create_gui(self, options: EpicsGUIOptions | None = None) -> None: - EpicsGUI(self._mapping, self._pv_prefix).create_gui(options) + def create_gui(self, gui_options: EpicsGUIOptions | None = None) -> None: + assert self.ioc_options.name_options is not None + EpicsGUI( + self._mapping, self._pv_prefix, self.ioc_options.name_options + ).create_gui(gui_options) - def _run(self, options: EpicsIOCOptions | None = None): - self._ioc.run(self._dispatcher, self._context, options) + def _run(self): + self._ioc.run(self._dispatcher, self._context) diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/backends/epics/gui.py index b9c48751..4a040ec6 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/backends/epics/gui.py @@ -27,6 +27,10 @@ from pydantic import ValidationError from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.backends.epics.util import ( + EpicsNameOptions, + _convert_attribute_name_to_pv_name, +) from fastcs.cs_methods import Command from fastcs.datatypes import Bool, Float, Int, String from fastcs.exceptions import FastCSException @@ -39,7 +43,7 @@ class EpicsGUIFormat(Enum): edl = ".edl" -@dataclass +@dataclass(frozen=True) class EpicsGUIOptions: output_path: Path = Path.cwd() / "output.bob" file_format: EpicsGUIFormat = EpicsGUIFormat.bob @@ -47,13 +51,37 @@ class EpicsGUIOptions: class EpicsGUI: - def __init__(self, mapping: Mapping, pv_prefix: str) -> None: + def __init__( + self, + mapping: Mapping, + pv_prefix: str, + epics_name_options: EpicsNameOptions | None = None, + ) -> None: self._mapping = mapping self._pv_prefix = pv_prefix + self.epics_name_options = epics_name_options or EpicsNameOptions() def _get_pv(self, attr_path: list[str], name: str): - attr_prefix = ":".join([self._pv_prefix] + attr_path) - return f"{attr_prefix}:{name.title().replace('_', '')}" + return self.epics_name_options.pv_separator.join( + [ + self._pv_prefix, + ] + + [ + _convert_attribute_name_to_pv_name( + attr_name, + self.epics_name_options.pv_naming_convention, + is_attribute=False, + ) + for attr_name in attr_path + ] + + [ + _convert_attribute_name_to_pv_name( + name, + self.epics_name_options.pv_naming_convention, + is_attribute=True, + ), + ], + ) @staticmethod def _get_read_widget(attribute: AttrR) -> ReadWidgetUnion: diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index 7aa0e892..f2aea9e1 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -10,6 +10,8 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.backends.epics.util import ( MBB_STATE_FIELDS, + EpicsNameOptions, + _convert_attribute_name_to_pv_name, attr_is_enum, enum_index_to_value, enum_value_to_index, @@ -22,32 +24,250 @@ EPICS_MAX_NAME_LENGTH = 60 -@dataclass +@dataclass(frozen=True) class EpicsIOCOptions: terminal: bool = True + name_options: EpicsNameOptions = EpicsNameOptions() class EpicsIOC: - def __init__(self, pv_prefix: str, mapping: Mapping): - _add_pvi_info(f"{pv_prefix}:PVI") - _add_sub_controller_pvi_info(pv_prefix, mapping.controller) + def __init__( + self, pv_prefix: str, mapping: Mapping, options: EpicsIOCOptions | None = None + ): + self._options = options or EpicsIOCOptions() + self._name_options = self._options.name_options - _create_and_link_attribute_pvs(pv_prefix, mapping) - _create_and_link_command_pvs(pv_prefix, mapping) + _add_pvi_info(f"{pv_prefix}{self._name_options.pv_separator}PVI") + self._add_sub_controller_pvi_info(pv_prefix, mapping.controller) + + self._create_and_link_attribute_pvs(pv_prefix, mapping) + self._create_and_link_command_pvs(pv_prefix, mapping) def run( self, dispatcher: AsyncioDispatcher, context: dict[str, Any], - options: EpicsIOCOptions | None = None, ) -> None: - if options is None: - options = EpicsIOCOptions() - builder.LoadDatabase() softioc.iocInit(dispatcher) - softioc.interactive_ioc(context) + if self._options.terminal: + softioc.interactive_ioc(context) + + def _add_sub_controller_pvi_info(self, pv_prefix: str, parent: BaseController): + """Add PVI references from controller to its sub controllers, recursively. + + Args: + pv_prefix: PV Prefix of IOC + parent: Controller to add PVI refs for + + """ + parent_pvi = self._name_options.pv_separator.join( + [pv_prefix] + parent.path + ["PVI"] + ) + + for child in parent.get_sub_controllers().values(): + child_pvi = self._name_options.pv_separator.join( + [pv_prefix] + + [ + _convert_attribute_name_to_pv_name( + path, + self._name_options.pv_naming_convention, + is_attribute=False, + ) + for path in child.path + ] + + ["PVI"] + ) + child_name = child.path[-1].lower() + + _add_pvi_info(child_pvi, parent_pvi, child_name) + + self._add_sub_controller_pvi_info(pv_prefix, child) + + def _create_and_link_attribute_pvs(self, pv_prefix: str, mapping: Mapping) -> None: + for single_mapping in mapping.get_controller_mappings(): + formatted_path = [ + _convert_attribute_name_to_pv_name( + p, self._name_options.pv_naming_convention, is_attribute=False + ) + for p in single_mapping.controller.path + ] + for attr_name, attribute in single_mapping.attributes.items(): + pv_name = _convert_attribute_name_to_pv_name( + attr_name, + self._name_options.pv_naming_convention, + is_attribute=True, + ) + _pv_prefix = self._name_options.pv_separator.join( + [pv_prefix] + formatted_path + ) + full_pv_name_length = len( + f"{_pv_prefix}{self._name_options.pv_separator}{pv_name}" + ) + + if full_pv_name_length > EPICS_MAX_NAME_LENGTH: + attribute.enabled = False + print( + f"Not creating PV for {attr_name} for controller" + f" {single_mapping.controller.path} as full name would exceed" + f" {EPICS_MAX_NAME_LENGTH} characters" + ) + continue + + match attribute: + case AttrRW(): + if full_pv_name_length > (EPICS_MAX_NAME_LENGTH - 4): + print( + f"Not creating PVs for {attr_name} as _RBV PV" + f" name would exceed {EPICS_MAX_NAME_LENGTH}" + " characters" + ) + attribute.enabled = False + else: + self._create_and_link_read_pv( + _pv_prefix, f"{pv_name}_RBV", attr_name, attribute + ) + self._create_and_link_write_pv( + _pv_prefix, pv_name, attr_name, attribute + ) + case AttrR(): + self._create_and_link_read_pv( + _pv_prefix, pv_name, attr_name, attribute + ) + case AttrW(): + self._create_and_link_write_pv( + _pv_prefix, pv_name, attr_name, attribute + ) + + def _create_and_link_read_pv( + self, pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrR[T] + ) -> None: + if attr_is_enum(attribute): + + async def async_record_set(value: T): + record.set(enum_value_to_index(attribute, value)) + + else: + + async def async_record_set(value: T): + record.set(value) + + record = _get_input_record( + f"{pv_prefix}{self._name_options.pv_separator}{pv_name}", attribute + ) + self._add_attr_pvi_info(record, pv_prefix, attr_name, "r") + + attribute.set_update_callback(async_record_set) + + def _create_and_link_command_pvs(self, pv_prefix: str, mapping: Mapping) -> None: + for single_mapping in mapping.get_controller_mappings(): + formatted_path = [ + _convert_attribute_name_to_pv_name( + p, self._name_options.pv_naming_convention, is_attribute=False + ) + for p in single_mapping.controller.path + ] + for attr_name, method in single_mapping.command_methods.items(): + pv_name = _convert_attribute_name_to_pv_name( + attr_name, + self._name_options.pv_naming_convention, + is_attribute=True, + ) + _pv_prefix = self._name_options.pv_separator.join( + [pv_prefix] + formatted_path + ) + if ( + len(f"{_pv_prefix}{self._name_options.pv_separator}{pv_name}") + > EPICS_MAX_NAME_LENGTH + ): + print( + f"Not creating PV for {attr_name} as full name would exceed" + f" {EPICS_MAX_NAME_LENGTH} characters" + ) + method.enabled = False + else: + self._create_and_link_command_pv( + _pv_prefix, + pv_name, + attr_name, + MethodType(method.fn, single_mapping.controller), + ) + + def _create_and_link_write_pv( + self, pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW[T] + ) -> None: + if attr_is_enum(attribute): + + async def on_update(value): + await attribute.process_without_display_update( + enum_index_to_value(attribute, value) + ) + + async def async_write_display(value: T): + record.set(enum_value_to_index(attribute, value), process=False) + + else: + + async def on_update(value): + await attribute.process_without_display_update(value) + + async def async_write_display(value: T): + record.set(value, process=False) + + record = _get_output_record( + f"{pv_prefix}{self._name_options.pv_separator}{pv_name}", + attribute, + on_update=on_update, + ) + + self._add_attr_pvi_info(record, pv_prefix, attr_name, "w") + + attribute.set_write_display_callback(async_write_display) + + def _create_and_link_command_pv( + self, pv_prefix: str, pv_name: str, attr_name: str, method: Callable + ) -> None: + async def wrapped_method(_: Any): + await method() + + record = builder.aOut( + f"{pv_prefix}{self._name_options.pv_separator}{pv_name}", + initial_value=0, + always_update=True, + on_update=wrapped_method, + ) + + self._add_attr_pvi_info(record, pv_prefix, attr_name, "x") + + def _add_attr_pvi_info( + self, + record: RecordWrapper, + prefix: str, + name: str, + access_mode: Literal["r", "w", "rw", "x"], + ): + """Add an info tag to a record to include it in the PVI for the controller. + + Args: + record: Record to add info tag to + prefix: PV prefix of controller + name: Name of parameter to add to PVI + access_mode: Access mode of parameter + + """ + record.add_info( + "Q:group", + { + f"{prefix}{self._name_options.pv_separator}PVI": { + f"value.{name}.{access_mode}": { + "+channel": "NAME", + "+type": "plain", + "+trigger": f"value.{name}.{access_mode}", + } + } + }, + ) def _add_pvi_info( @@ -95,82 +315,6 @@ def _add_pvi_info( record.add_info("Q:group", q_group) -def _add_sub_controller_pvi_info(pv_prefix: str, parent: BaseController): - """Add PVI references from controller to its sub controllers, recursively. - - Args: - pv_prefix: PV Prefix of IOC - parent: Controller to add PVI refs for - - """ - parent_pvi = ":".join([pv_prefix] + parent.path + ["PVI"]) - - for child in parent.get_sub_controllers().values(): - child_pvi = ":".join([pv_prefix] + child.path + ["PVI"]) - child_name = child.path[-1].lower() - - _add_pvi_info(child_pvi, parent_pvi, child_name) - - _add_sub_controller_pvi_info(pv_prefix, child) - - -def _create_and_link_attribute_pvs(pv_prefix: str, mapping: Mapping) -> None: - for single_mapping in mapping.get_controller_mappings(): - path = single_mapping.controller.path - for attr_name, attribute in single_mapping.attributes.items(): - pv_name = attr_name.title().replace("_", "") - _pv_prefix = ":".join([pv_prefix] + path) - full_pv_name_length = len(f"{_pv_prefix}:{pv_name}") - - if full_pv_name_length > EPICS_MAX_NAME_LENGTH: - attribute.enabled = False - print( - f"Not creating PV for {attr_name} for controller" - f" {single_mapping.controller.path} as full name would exceed" - f" {EPICS_MAX_NAME_LENGTH} characters" - ) - continue - - match attribute: - case AttrRW(): - if full_pv_name_length > (EPICS_MAX_NAME_LENGTH - 4): - print( - f"Not creating PVs for {attr_name} as _RBV PV" - f" name would exceed {EPICS_MAX_NAME_LENGTH}" - " characters" - ) - attribute.enabled = False - else: - _create_and_link_read_pv( - _pv_prefix, f"{pv_name}_RBV", attr_name, attribute - ) - _create_and_link_write_pv( - _pv_prefix, pv_name, attr_name, attribute - ) - case AttrR(): - _create_and_link_read_pv(_pv_prefix, pv_name, attr_name, attribute) - case AttrW(): - _create_and_link_write_pv(_pv_prefix, pv_name, attr_name, attribute) - - -def _create_and_link_read_pv( - pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrR[T] -) -> None: - if attr_is_enum(attribute): - - async def async_record_set(value: T): - record.set(enum_value_to_index(attribute, value)) - else: - - async def async_record_set(value: T): - record.set(value) - - record = _get_input_record(f"{pv_prefix}:{pv_name}", attribute) - _add_attr_pvi_info(record, pv_prefix, attr_name, "r") - - attribute.set_update_callback(async_record_set) - - def _get_input_record(pv: str, attribute: AttrR) -> RecordWrapper: if attr_is_enum(attribute): assert attribute.allowed_values is not None and all( @@ -194,39 +338,10 @@ def _get_input_record(pv: str, attribute: AttrR) -> RecordWrapper: ) -def _create_and_link_write_pv( - pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW[T] -) -> None: - if attr_is_enum(attribute): - - async def on_update(value): - await attribute.process_without_display_update( - enum_index_to_value(attribute, value) - ) - - async def async_write_display(value: T): - record.set(enum_value_to_index(attribute, value), process=False) - - else: - - async def on_update(value): - await attribute.process_without_display_update(value) - - async def async_write_display(value: T): - record.set(value, process=False) - - record = _get_output_record( - f"{pv_prefix}:{pv_name}", attribute, on_update=on_update - ) - _add_attr_pvi_info(record, pv_prefix, attr_name, "w") - - attribute.set_write_display_callback(async_write_display) - - def _get_output_record(pv: str, attribute: AttrW, on_update: Callable) -> Any: if attr_is_enum(attribute): assert attribute.allowed_values is not None and all( - isinstance(v, str) for v in attribute.allowed_values + isinstance(v, str) or isinstance(v, int) 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) @@ -250,69 +365,3 @@ def _get_output_record(pv: str, attribute: AttrW, on_update: Callable) -> Any: raise FastCSException( f"Unsupported type {type(attribute.datatype)}: {attribute.datatype}" ) - - -def _create_and_link_command_pvs(pv_prefix: str, mapping: Mapping) -> None: - for single_mapping in mapping.get_controller_mappings(): - path = single_mapping.controller.path - for attr_name, method in single_mapping.command_methods.items(): - pv_name = attr_name.title().replace("_", "") - _pv_prefix = ":".join([pv_prefix] + path) - if len(f"{_pv_prefix}:{pv_name}") > EPICS_MAX_NAME_LENGTH: - print( - f"Not creating PV for {attr_name} as full name would exceed" - f" {EPICS_MAX_NAME_LENGTH} characters" - ) - method.enabled = False - else: - _create_and_link_command_pv( - _pv_prefix, - pv_name, - attr_name, - MethodType(method.fn, single_mapping.controller), - ) - - -def _create_and_link_command_pv( - pv_prefix: str, pv_name: str, attr_name: str, method: Callable -) -> None: - async def wrapped_method(_: Any): - await method() - - record = builder.aOut( - f"{pv_prefix}:{pv_name}", - initial_value=0, - always_update=True, - on_update=wrapped_method, - ) - - _add_attr_pvi_info(record, pv_prefix, attr_name, "x") - - -def _add_attr_pvi_info( - record: RecordWrapper, - prefix: str, - name: str, - access_mode: Literal["r", "w", "rw", "x"], -): - """Add an info tag to a record to include it in the PVI for the controller. - - Args: - record: Record to add info tag to - prefix: PV prefix of controller - name: Name of parameter to add to PVI - access_mode: Access mode of parameter - - """ - record.add_info( - "Q:group", - { - f"{prefix}:PVI": { - f"value.{name}.{access_mode}": { - "+channel": "NAME", - "+type": "plain", - "+trigger": f"value.{name}.{access_mode}", - } - } - }, - ) diff --git a/src/fastcs/backends/epics/util.py b/src/fastcs/backends/epics/util.py index b1ffa608..973cef43 100644 --- a/src/fastcs/backends/epics/util.py +++ b/src/fastcs/backends/epics/util.py @@ -1,3 +1,6 @@ +from dataclasses import dataclass +from enum import Enum + from fastcs.attributes import Attribute from fastcs.datatypes import String, T @@ -25,6 +28,42 @@ MBB_MAX_CHOICES = len(_MBB_FIELD_PREFIXES) +class PvNamingConvention(Enum): + NO_CONVERSION = "NO_CONVERSION" + PASCAL = "PASCAL" + CAPITALIZED = "CAPITALIZED" + CAPITALIZED_CONTROLLER_PASCAL_ATTRIBUTE = "CAPITALIZED_CONTROLLER_PASCAL_ATTRIBUTE" + + +DEFAULT_PV_SEPARATOR = ":" + + +@dataclass(frozen=True) +class EpicsNameOptions: + pv_naming_convention: PvNamingConvention = PvNamingConvention.PASCAL + pv_separator: str = DEFAULT_PV_SEPARATOR + + +def _convert_attribute_name_to_pv_name( + attr_name: str, naming_convention: PvNamingConvention, is_attribute: bool = False +) -> str: + if naming_convention == PvNamingConvention.PASCAL: + return attr_name.title().replace("_", "") + elif naming_convention == PvNamingConvention.CAPITALIZED: + return attr_name.upper().replace("_", "-") + elif ( + naming_convention == PvNamingConvention.CAPITALIZED_CONTROLLER_PASCAL_ATTRIBUTE + ): + if is_attribute: + return _convert_attribute_name_to_pv_name( + attr_name, PvNamingConvention.PASCAL, is_attribute + ) + return _convert_attribute_name_to_pv_name( + attr_name, PvNamingConvention.CAPITALIZED + ) + return attr_name + + def attr_is_enum(attribute: Attribute) -> bool: """Check if the `Attribute` has a `String` datatype and has `allowed_values` set. @@ -36,9 +75,9 @@ def attr_is_enum(attribute: Attribute) -> bool: """ match attribute: - case Attribute( - datatype=String(), allowed_values=allowed_values - ) if allowed_values is not None and len(allowed_values) <= MBB_MAX_CHOICES: + case Attribute(datatype=String(), allowed_values=allowed_values) if ( + allowed_values is not None and len(allowed_values) <= MBB_MAX_CHOICES + ): return True case _: return False diff --git a/tests/backends/epics/test_ioc.py b/tests/backends/epics/test_ioc.py index c1a5b321..84655fec 100644 --- a/tests/backends/epics/test_ioc.py +++ b/tests/backends/epics/test_ioc.py @@ -7,15 +7,13 @@ from fastcs.backends.epics.ioc import ( EPICS_MAX_NAME_LENGTH, EpicsIOC, - _add_attr_pvi_info, + EpicsIOCOptions, _add_pvi_info, - _add_sub_controller_pvi_info, - _create_and_link_read_pv, - _create_and_link_write_pv, _get_input_record, _get_output_record, ) -from fastcs.controller import Controller +from fastcs.backends.epics.util import EpicsNameOptions, PvNamingConvention +from fastcs.controller import Controller, SubController from fastcs.cs_methods import Command from fastcs.datatypes import Int, String from fastcs.exceptions import FastCSException @@ -27,17 +25,31 @@ ONOFF_STATES = {"ZRST": "disabled", "ONST": "enabled"} +@pytest.fixture +def ioc_without_mapping(mocker: MockerFixture, mapping: Mapping): + mocker.patch("fastcs.backends.epics.ioc.builder") + mocker.patch("fastcs.backends.epics.ioc.EpicsIOC._create_and_link_attribute_pvs") + mocker.patch("fastcs.backends.epics.ioc.EpicsIOC._create_and_link_command_pvs") + + return EpicsIOC(DEVICE, mapping) + + @pytest.mark.asyncio -async def test_create_and_link_read_pv(mocker: MockerFixture): +async def test_create_and_link_read_pv( + mocker: MockerFixture, ioc_without_mapping: EpicsIOC +): get_input_record = mocker.patch("fastcs.backends.epics.ioc._get_input_record") - add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info") attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum") + mocker.patch("fastcs.backends.epics.ioc._add_pvi_info") + add_attr_pvi_info = mocker.patch( + "fastcs.backends.epics.ioc.EpicsIOC._add_attr_pvi_info" + ) record = get_input_record.return_value attribute = mocker.MagicMock() - attr_is_enum.return_value = False - _create_and_link_read_pv("PREFIX", "PV", "attr", attribute) + + ioc_without_mapping._create_and_link_read_pv("PREFIX", "PV", "attr", attribute) get_input_record.assert_called_once_with("PREFIX:PV", attribute) add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "r") @@ -51,9 +63,13 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): @pytest.mark.asyncio -async def test_create_and_link_read_pv_enum(mocker: MockerFixture): +async def test_create_and_link_read_pv_enum( + mocker: MockerFixture, ioc_without_mapping: EpicsIOC +): get_input_record = mocker.patch("fastcs.backends.epics.ioc._get_input_record") - add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info") + add_attr_pvi_info = mocker.patch( + "fastcs.backends.epics.ioc.EpicsIOC._add_attr_pvi_info" + ) attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum") record = get_input_record.return_value enum_value_to_index = mocker.patch("fastcs.backends.epics.ioc.enum_value_to_index") @@ -61,7 +77,7 @@ async def test_create_and_link_read_pv_enum(mocker: MockerFixture): attribute = mocker.MagicMock() attr_is_enum.return_value = True - _create_and_link_read_pv("PREFIX", "PV", "attr", attribute) + ioc_without_mapping._create_and_link_read_pv("PREFIX", "PV", "attr", attribute) get_input_record.assert_called_once_with("PREFIX:PV", attribute) add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "r") @@ -108,9 +124,13 @@ def test_get_input_record_raises(mocker: MockerFixture): @pytest.mark.asyncio -async def test_create_and_link_write_pv(mocker: MockerFixture): +async def test_create_and_link_write_pv( + mocker: MockerFixture, ioc_without_mapping: EpicsIOC +): get_output_record = mocker.patch("fastcs.backends.epics.ioc._get_output_record") - add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info") + add_attr_pvi_info = mocker.patch( + "fastcs.backends.epics.ioc.EpicsIOC._add_attr_pvi_info" + ) attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum") record = get_output_record.return_value @@ -118,7 +138,7 @@ async def test_create_and_link_write_pv(mocker: MockerFixture): attribute.process_without_display_update = mocker.AsyncMock() attr_is_enum.return_value = False - _create_and_link_write_pv("PREFIX", "PV", "attr", attribute) + ioc_without_mapping._create_and_link_write_pv("PREFIX", "PV", "attr", attribute) get_output_record.assert_called_once_with( "PREFIX:PV", attribute, on_update=mocker.ANY @@ -140,9 +160,13 @@ async def test_create_and_link_write_pv(mocker: MockerFixture): @pytest.mark.asyncio -async def test_create_and_link_write_pv_enum(mocker: MockerFixture): +async def test_create_and_link_write_pv_enum( + mocker: MockerFixture, ioc_without_mapping: EpicsIOC +): get_output_record = mocker.patch("fastcs.backends.epics.ioc._get_output_record") - add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info") + add_attr_pvi_info = mocker.patch( + "fastcs.backends.epics.ioc.EpicsIOC._add_attr_pvi_info" + ) attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum") enum_value_to_index = mocker.patch("fastcs.backends.epics.ioc.enum_value_to_index") enum_index_to_value = mocker.patch("fastcs.backends.epics.ioc.enum_index_to_value") @@ -152,7 +176,7 @@ async def test_create_and_link_write_pv_enum(mocker: MockerFixture): attribute.process_without_display_update = mocker.AsyncMock() attr_is_enum.return_value = True - _create_and_link_write_pv("PREFIX", "PV", "attr", attribute) + ioc_without_mapping._create_and_link_write_pv("PREFIX", "PV", "attr", attribute) get_output_record.assert_called_once_with( "PREFIX:PV", attribute, on_update=mocker.ANY @@ -215,7 +239,7 @@ 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") add_sub_controller_pvi_info = mocker.patch( - "fastcs.backends.epics.ioc._add_sub_controller_pvi_info" + "fastcs.backends.epics.ioc.EpicsIOC._add_sub_controller_pvi_info" ) EpicsIOC(DEVICE, mapping) @@ -225,7 +249,10 @@ def test_ioc(mocker: MockerFixture, mapping: Mapping): builder.longIn.assert_any_call(f"{DEVICE}:ReadInt") builder.aIn.assert_called_once_with(f"{DEVICE}:ReadWriteFloat_RBV", PREC=2) 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, ) builder.longIn.assert_any_call(f"{DEVICE}:BigEnum") builder.longIn.assert_any_call(f"{DEVICE}:ReadWriteInt_RBV") @@ -323,7 +350,9 @@ def test_add_pvi_info_with_parent(mocker: MockerFixture): ) -def test_add_sub_controller_pvi_info(mocker: MockerFixture): +def test_add_sub_controller_pvi_info( + mocker: MockerFixture, ioc_without_mapping: EpicsIOC +): add_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_pvi_info") controller = mocker.MagicMock() controller.path = [] @@ -331,17 +360,17 @@ def test_add_sub_controller_pvi_info(mocker: MockerFixture): child.path = ["Child"] controller.get_sub_controllers.return_value = {"d": child} - _add_sub_controller_pvi_info(DEVICE, controller) + ioc_without_mapping._add_sub_controller_pvi_info(DEVICE, controller) add_pvi_info.assert_called_once_with( f"{DEVICE}:Child:PVI", f"{DEVICE}:PVI", "child" ) -def test_add_attr_pvi_info(mocker: MockerFixture): +def test_add_attr_pvi_info(mocker: MockerFixture, ioc_without_mapping: EpicsIOC): record = mocker.MagicMock() - _add_attr_pvi_info(record, DEVICE, "attr", "r") + ioc_without_mapping._add_attr_pvi_info(record, DEVICE, "attr", "r") record.add_info.assert_called_once_with( "Q:group", @@ -387,13 +416,9 @@ def test_long_pv_names_discarded(mocker: MockerFixture): short_pv_name = "attr_rw_short_name".title().replace("_", "") builder.longOut.assert_called_once_with( - f"{DEVICE}:{short_pv_name}", - always_update=True, - on_update=mocker.ANY, - ) - builder.longIn.assert_called_once_with( - f"{DEVICE}:{short_pv_name}_RBV", + f"{DEVICE}:{short_pv_name}", always_update=True, on_update=mocker.ANY ) + builder.longIn.assert_called_once_with(f"{DEVICE}:{short_pv_name}_RBV") long_pv_name = long_attr_name.title().replace("_", "") with pytest.raises(AssertionError): @@ -438,3 +463,76 @@ def test_long_pv_names_discarded(mocker: MockerFixture): always_update=True, on_update=mocker.ANY, ) + + +class FooSubController(SubController): + def __init__(self): + self.foo_bar_2 = AttrR(Int()) + super().__init__() + + +class FooController(Controller): + def __init__(self): + self.foo_bar_1 = AttrR(Int()) + self.sub_controller = FooSubController() + super().__init__() + self.register_sub_controller("sub_controller", self.sub_controller) + + +@pytest.mark.parametrize( + ("naming_convention", "separator", "foo_bar_1", "sub_controller", "foo_bar_2"), + [ + ( + PvNamingConvention.NO_CONVERSION, + "&", + f"{DEVICE}&foo_bar_1", + f"{DEVICE}&sub_controller&PVI", + f"{DEVICE}&sub_controller&foo_bar_2", + ), + ( + PvNamingConvention.PASCAL, + ":", + f"{DEVICE}:FooBar1", + f"{DEVICE}:SubController:PVI", + f"{DEVICE}:SubController:FooBar2", + ), + ( + PvNamingConvention.CAPITALIZED, + ":", + f"{DEVICE}:FOO-BAR-1", + f"{DEVICE}:SUB-CONTROLLER:PVI", + f"{DEVICE}:SUB-CONTROLLER:FOO-BAR-2", + ), + ( + PvNamingConvention.CAPITALIZED_CONTROLLER_PASCAL_ATTRIBUTE, + ":", + f"{DEVICE}:FooBar1", + f"{DEVICE}:SUB-CONTROLLER:PVI", + f"{DEVICE}:SUB-CONTROLLER:FooBar2", + ), + ], +) +def test_pv_naming_conventions( + mocker: MockerFixture, + naming_convention: PvNamingConvention, + separator: str, + foo_bar_1: str, + sub_controller: str, + foo_bar_2: str, +): + options = EpicsIOCOptions( + name_options=EpicsNameOptions( + pv_naming_convention=naming_convention, pv_separator=separator + ) + ) + builder = mocker.patch("fastcs.backends.epics.ioc.builder") + controller = FooController() + mapping = Mapping(controller) + EpicsIOC(DEVICE, mapping, options=options) + builder.longIn.assert_any_call(foo_bar_1) + builder.longIn.assert_any_call(foo_bar_2) + builder.longStringIn.assert_any_call( + f"{sub_controller}_PV", + initial_value=f"{sub_controller}", + DESC="The records in this controller", + )