From 231f9b64e5826b17376c4113fa164a9b51bf0059 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Thu, 28 May 2026 14:14:16 +0000 Subject: [PATCH 1/2] feat: allow aliases to be passed to ca transport --- src/fastcs/demo/schema.json | 10 ++++- src/fastcs/transports/epics/ca/ioc.py | 44 ++++++++++++++++----- src/fastcs/transports/epics/ca/transport.py | 2 +- src/fastcs/transports/epics/options.py | 4 +- tests/data/schema.json | 10 ++++- tests/transports/epics/ca/test_softioc.py | 36 ++++++++++++++--- 6 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/fastcs/demo/schema.json b/src/fastcs/demo/schema.json index 7fc6150e..210c3625 100644 --- a/src/fastcs/demo/schema.json +++ b/src/fastcs/demo/schema.json @@ -2,7 +2,15 @@ "$defs": { "EpicsCAOptions": { "additionalProperties": false, - "properties": {}, + "properties": { + "aliases": { + "additionalProperties": { + "type": "string" + }, + "title": "Aliases", + "type": "object" + } + }, "title": "EpicsCAOptions", "type": "object" }, diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 7ac5026d..a6a032c0 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -26,14 +26,14 @@ class EpicsCAIOC: """A softioc which handles one or more controllers.""" - def __init__(self, controller_apis: list[ControllerAPI]): + def __init__(self, controller_apis: list[ControllerAPI], aliases: dict[str, str]): self._controller_apis = controller_apis for controller_api in controller_apis: root_pv_prefix = pv_prefix_from_path(controller_api.path) _add_pvi_info(f"{root_pv_prefix}:PVI") _add_sub_controller_pvi_info(controller_api) - _create_and_link_attribute_pvs(controller_api) + _create_and_link_attribute_pvs(controller_api, aliases) _create_and_link_command_pvs(controller_api) def run( @@ -107,7 +107,9 @@ def _add_sub_controller_pvi_info(parent: ControllerAPI): _add_sub_controller_pvi_info(child) -def _create_and_link_attribute_pvs(root_controller_api: ControllerAPI) -> None: +def _create_and_link_attribute_pvs( + root_controller_api: ControllerAPI, aliases: dict[str, str] +) -> None: for controller_api in root_controller_api.walk_api(): pv_prefix = pv_prefix_from_path(controller_api.path) @@ -133,6 +135,7 @@ def _create_and_link_attribute_pvs(root_controller_api: ControllerAPI) -> None: ) continue + alias = aliases.get(f"{pv_prefix}:{pv_name}", None) match attribute: case AttrRW(): if full_pv_name_length > (EPICS_MAX_NAME_LENGTH - 4): @@ -144,19 +147,31 @@ def _create_and_link_attribute_pvs(root_controller_api: ControllerAPI) -> None: attribute.enabled = False else: _create_and_link_read_pv( - pv_prefix, f"{pv_name}_RBV", attr_name, attribute + pv_prefix, f"{pv_name}_RBV", attr_name, alias, attribute ) _create_and_link_write_pv( - pv_prefix, pv_name, attr_name, attribute + pv_prefix, pv_name, attr_name, alias, attribute ) case AttrR(): - _create_and_link_read_pv(pv_prefix, pv_name, attr_name, attribute) + _create_and_link_read_pv( + pv_prefix, + pv_name, + attr_name, + alias, + attribute, + ) case AttrW(): - _create_and_link_write_pv(pv_prefix, pv_name, attr_name, attribute) + _create_and_link_write_pv( + pv_prefix, pv_name, attr_name, alias, attribute + ) def _create_and_link_read_pv( - pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrR[DType_T] + pv_prefix: str, + pv_name: str, + attr_name: str, + alias: str | None, + attribute: AttrR[DType_T], ) -> None: pv = f"{pv_prefix}:{pv_name}" @@ -168,13 +183,22 @@ async def async_record_set(value: DType_T): record.set(cast_to_epics_type(attribute.datatype, value)) record = _make_in_record(pv, attribute) + + if alias: + suffix = "_RBV" if pv_name.endswith("_RBV") else "" + record.add_alias(f"{alias}{suffix}") + _add_attr_pvi_info(record, pv_prefix, attr_name, "r") attribute.add_on_update_callback(async_record_set) def _create_and_link_write_pv( - pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW[DType_T] + pv_prefix: str, + pv_name: str, + attr_name: str, + alias: str | None, + attribute: AttrW[DType_T], ) -> None: pv = f"{pv_prefix}:{pv_name}" @@ -191,6 +215,8 @@ async def set_setpoint_without_process(value: DType_T): record.set(cast_to_epics_type(attribute.datatype, value), process=False) record = _make_out_record(pv, attribute, on_update=on_update) + if alias: + record.add_alias(alias) _add_attr_pvi_info(record, pv_prefix, attr_name, "w") diff --git a/src/fastcs/transports/epics/ca/transport.py b/src/fastcs/transports/epics/ca/transport.py index 8be1ca13..54722f0f 100644 --- a/src/fastcs/transports/epics/ca/transport.py +++ b/src/fastcs/transports/epics/ca/transport.py @@ -43,7 +43,7 @@ def connect( self._controller_apis = controller_apis self._loop = loop self._pv_prefixes = [pv_prefix_from_path(api.path) for api in controller_apis] - self._ioc = EpicsCAIOC(controller_apis) + self._ioc = EpicsCAIOC(controller_apis, self.epicsca.aliases) if self.docs is not None: emit_docs_files(controller_apis, self.docs) diff --git a/src/fastcs/transports/epics/options.py b/src/fastcs/transports/epics/options.py index 03821a0f..1baf9791 100644 --- a/src/fastcs/transports/epics/options.py +++ b/src/fastcs/transports/epics/options.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import ClassVar @@ -41,6 +41,8 @@ class EpicsCAOptions: __pydantic_config__: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + aliases: dict[str, str] = field(default_factory=dict) + @dataclass class EpicsPVAOptions: diff --git a/tests/data/schema.json b/tests/data/schema.json index 5e9d800f..ce2d1e1f 100644 --- a/tests/data/schema.json +++ b/tests/data/schema.json @@ -2,7 +2,15 @@ "$defs": { "EpicsCAOptions": { "additionalProperties": false, - "properties": {}, + "properties": { + "aliases": { + "additionalProperties": { + "type": "string" + }, + "title": "Aliases", + "type": "object" + } + }, "title": "EpicsCAOptions", "type": "object" }, diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py index 2ad7a601..f032a738 100644 --- a/tests/transports/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -53,7 +53,7 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): attribute = AttrR(Int()) attribute.add_on_update_callback = mocker.MagicMock() - _create_and_link_read_pv("PREFIX", "PV", "attr", attribute) + _create_and_link_read_pv("PREFIX", "PV", "attr", None, attribute) make_record.assert_called_once_with("PREFIX:PV", attribute) add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "r") @@ -66,6 +66,32 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): record.set.assert_called_once_with(1) +@pytest.mark.asyncio +async def test_create_and_link_write_pv_adds_alias(mocker: MockerFixture): + make_record = mocker.patch("fastcs.transports.epics.ca.ioc._make_out_record") + record = make_record.return_value + record.add_alias = mocker.MagicMock() + attribute = mocker.MagicMock() + + _create_and_link_write_pv("PREFIX", "PV", "attr", "alias", attribute) + + make_record.assert_called_once_with("PREFIX:PV", attribute, on_update=mocker.ANY) + record.add_alias.assert_called_once_with("alias") + + +@pytest.mark.asyncio +async def test_create_and_link_read_pv_adds_alias_with_rbv(mocker: MockerFixture): + make_record = mocker.patch("fastcs.transports.epics.ca.ioc._make_in_record") + record = make_record.return_value + record.add_alias = mocker.MagicMock() + attribute = mocker.MagicMock() + + _create_and_link_read_pv("PREFIX", "PV_RBV", "attr", "alias", attribute) + + make_record.assert_called_once_with("PREFIX:PV_RBV", attribute) + record.add_alias.assert_called_once_with("alias_RBV") + + @pytest.mark.parametrize( "attribute,record_type,kwargs", ( @@ -150,7 +176,7 @@ async def test_create_and_link_write_pv(mocker: MockerFixture): attribute.put = mocker.AsyncMock() attribute.add_sync_setpoint_callback = mocker.MagicMock() - _create_and_link_write_pv("PREFIX", "PV", "attr", attribute) + _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") @@ -284,7 +310,7 @@ def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): "fastcs.transports.epics.ca.ioc._add_sub_controller_pvi_info" ) - EpicsCAIOC([epics_controller_api]) + EpicsCAIOC([epics_controller_api], {}) # Check records are created util_builder.boolIn.assert_called_once_with( @@ -510,7 +536,7 @@ def test_long_pv_names_discarded(mocker: MockerFixture): long_rw_name = "attr_rw_with_a_reallyreally_long_name_that_is_too_long_for_RBV" assert long_name_controller_api.attributes["attr_rw_short_name"].enabled assert long_name_controller_api.attributes[long_attr_name].enabled - EpicsCAIOC([long_name_controller_api]) + EpicsCAIOC([long_name_controller_api], {}) assert long_name_controller_api.attributes["attr_rw_short_name"].enabled assert not long_name_controller_api.attributes[long_attr_name].enabled @@ -599,7 +625,7 @@ def test_non_1d_waveforms_discarded(mocker: MockerFixture): create_mock = mocker.patch( "fastcs.transports.epics.ca.ioc._create_and_link_read_pv" ) - EpicsCAIOC([api]) + EpicsCAIOC([api], {}) create_mock.assert_called_once_with( DEVICE, "Waveform1d", "waveform_1d", api.attributes["waveform_1d"] From 7e6acf75ed303e2bdad28c9eceddb2c19124ae93 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Thu, 28 May 2026 14:23:31 +0000 Subject: [PATCH 2/2] tests: amend test to pass None for aliases --- tests/transports/epics/ca/test_softioc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py index f032a738..cfc1ec00 100644 --- a/tests/transports/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -628,7 +628,7 @@ def test_non_1d_waveforms_discarded(mocker: MockerFixture): EpicsCAIOC([api], {}) create_mock.assert_called_once_with( - DEVICE, "Waveform1d", "waveform_1d", api.attributes["waveform_1d"] + DEVICE, "Waveform1d", "waveform_1d", None, api.attributes["waveform_1d"] )