From a4136726cc05cb3d3c58f34fb8c0429d215661bc Mon Sep 17 00:00:00 2001 From: Shihab S <162436767+shihab-dls@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:42:22 +0100 Subject: [PATCH 1/3] Disable PVA in IOC initialization --- src/fastcs/transports/epics/ca/ioc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 7f210c42..93b55df7 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -54,7 +54,7 @@ def run( ) -> None: dispatcher = AsyncioDispatcher(loop) # Needs running loop builder.LoadDatabase() - softioc.iocInit(dispatcher) + softioc.iocInit(dispatcher, enable_pva=False) def _add_pvi_info( From fdab15b54117cd1bdfcf6777fab292dcddf8652e Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Mon, 14 Sep 2026 11:13:40 +0000 Subject: [PATCH 2/3] fix: remove PVI operations from CA and amend tests --- src/fastcs/transports/epics/ca/ioc.py | 112 +-------------- tests/transports/epics/ca/test_softioc.py | 129 +----------------- .../epics/ca/test_softioc_system.py | 81 ++++------- 3 files changed, 37 insertions(+), 285 deletions(-) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 93b55df7..0505bc6c 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -1,7 +1,7 @@ import asyncio from collections import Counter from collections.abc import Awaitable -from typing import Any, Literal +from typing import Any from softioc import alarm, builder, softioc from softioc.asyncio_dispatcher import AsyncioDispatcher @@ -41,10 +41,6 @@ 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, aliases) _create_and_link_command_pvs(controller_api, aliases) @@ -57,68 +53,6 @@ def run( softioc.iocInit(dispatcher, enable_pva=False) -def _add_pvi_info( - pvi: str, - parent_pvi: str = "", - name: str = "", -): - """Add PVI metadata for a controller. - - Args: - pvi: PVI PV of controller - parent_pvi: PVI PV of parent controller - name: Name to register controller with parent as - - """ - # Create a record to attach the info tags to - record = builder.longStringIn( - f"{pvi}_PV", - initial_value=pvi, - DESC="The records in this controller", - ) - - # Create PVI PV in preparation for adding attribute info tags to it - q_group = { - pvi: { - "+id": "epics:nt/NTPVI:1.0", - "display.description": {"+type": "plain", "+channel": "DESC"}, - "": {"+type": "meta", "+channel": "VAL"}, - } - } - # If this controller has a parent, add a link in the parent to this controller - if parent_pvi and name: - q_group.update( - { - parent_pvi: { - f"value.{name}.d": { - "+channel": "VAL", - "+type": "plain", - "+trigger": f"value.{name}.d", - } - } - } - ) - - record.add_info("Q:group", q_group) - - -def _add_sub_controller_pvi_info(parent: ControllerAPI): - """Add PVI references from controller to its sub controllers, recursively.""" - parent_pvi = f"{pv_prefix_from_path(parent.path)}:PVI" - - for child in parent.sub_apis.values(): - child_pvi = f"{pv_prefix_from_path(child.path)}:PVI" - child_name = ( - f"__{child.path[-1]}" # Sub-Controller of ControllerVector - if child.path[-1].isdigit() - else child.path[-1] - ) - - _add_pvi_info(child_pvi, parent_pvi, child_name.lower()) - - _add_sub_controller_pvi_info(child) - - def _create_and_link_attribute_pvs( root_controller_api: ControllerAPI, aliases: dict[str, str] ) -> None: @@ -164,14 +98,12 @@ def _create_and_link_attribute_pvs( _create_and_link_read_pv( pv_prefix, f"{pv_name}{RBV_SUFFIX}", - attr_name, alias_rbv, attribute, ) _create_and_link_write_pv( pv_prefix, pv_name, - attr_name, alias, attribute, ) @@ -179,7 +111,6 @@ def _create_and_link_attribute_pvs( _create_and_link_read_pv( pv_prefix, pv_name, - attr_name, alias, attribute, ) @@ -187,7 +118,6 @@ def _create_and_link_attribute_pvs( _create_and_link_write_pv( pv_prefix, pv_name, - attr_name, alias, attribute, ) @@ -196,7 +126,6 @@ def _create_and_link_attribute_pvs( def _create_and_link_read_pv( pv_prefix: str, pv_name: str, - attr_name: str, alias: str | None, attribute: AttrR[DType_T], ) -> None: @@ -213,15 +142,12 @@ async def async_record_set(value: DType_T): _add_alias(record, alias) - _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, alias: str | None, attribute: AttrW[DType_T], ): @@ -244,8 +170,6 @@ async def set_setpoint_without_process(value: DType_T): _add_alias(record, alias) - _add_attr_pvi_info(record, pv_prefix, attr_name, "w") - attribute.add_sync_setpoint_callback(set_setpoint_without_process) @@ -269,14 +193,13 @@ def _create_and_link_command_pvs( _create_and_link_command_pv( pv_prefix, pv_name, - attr_name, alias, method, ) def _create_and_link_command_pv( - pv_prefix: str, pv_name: str, attr_name: str, alias: str | None, method: Command + pv_prefix: str, pv_name: str, alias: str | None, method: Command ) -> None: pv = f"{pv_prefix}:{pv_name}" @@ -295,37 +218,6 @@ async def wrapped_method(_: Any): _add_alias(record, alias) - _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}", - } - } - }, - ) - def _add_alias(record: RecordWrapper, alias: str | None): if alias is not None: diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py index 8b7c1220..b270bca6 100644 --- a/tests/transports/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -22,9 +22,6 @@ from fastcs.transports.epics.ca.ioc import ( EpicsCAIOC, _add_alias, - _add_attr_pvi_info, - _add_pvi_info, - _add_sub_controller_pvi_info, _create_and_link_command_pv, _create_and_link_read_pv, _create_and_link_write_pv, @@ -48,18 +45,14 @@ 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_in_record") - add_attr_pvi_info = mocker.patch( - "fastcs.transports.epics.ca.ioc._add_attr_pvi_info" - ) record = make_record.return_value attribute = AttrR(Int()) attribute.add_on_update_callback = mocker.MagicMock() - _create_and_link_read_pv("PREFIX", "PV", "attr", None, attribute) + _create_and_link_read_pv("PREFIX", "PV", None, attribute) make_record.assert_called_once_with("PREFIX:PV", attribute) - add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "r") # Extract the callback generated and set in the function and call it attribute.add_on_update_callback.assert_called_once_with(mocker.ANY) @@ -76,7 +69,7 @@ async def test_create_and_link_write_pv_adds_alias(mocker: MockerFixture): record.add_alias = mocker.MagicMock() attribute = mocker.MagicMock() - _create_and_link_write_pv("PREFIX", "PV", "attr", "alias", attribute) + _create_and_link_write_pv("PREFIX", "PV", "alias", attribute) make_record.assert_called_once_with("PREFIX:PV", attribute, on_update=mocker.ANY) record.add_alias.assert_called_once_with("alias") @@ -89,7 +82,7 @@ async def test_create_and_link_read_pv_adds_alias(mocker: MockerFixture): record.add_alias = mocker.MagicMock() attribute = mocker.MagicMock() - _create_and_link_read_pv("PREFIX", "PV_RBV", "attr", "alias", attribute) + _create_and_link_read_pv("PREFIX", "PV_RBV", "alias", attribute) make_record.assert_called_once_with("PREFIX:PV_RBV", attribute) record.add_alias.assert_called_once_with("alias") @@ -102,7 +95,7 @@ async def test_create_and_link_command_pv_adds_alias(mocker: MockerFixture): record.add_alias = mocker.MagicMock() command = mocker.MagicMock() - _create_and_link_command_pv("PREFIX", "Command", "command", "alias", command) + _create_and_link_command_pv("PREFIX", "Command", "alias", command) make_action.assert_called_once_with( "PREFIX:Command", @@ -223,19 +216,15 @@ def test_make_record_raises(mocker: MockerFixture): @pytest.mark.asyncio async def test_create_and_link_write_pv(mocker: MockerFixture): 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" - ) record = make_record.return_value attribute = AttrW(Int()) attribute.put = mocker.AsyncMock() attribute.add_sync_setpoint_callback = mocker.MagicMock() - _create_and_link_write_pv("PREFIX", "PV", "attr", None, attribute) + _create_and_link_write_pv("PREFIX", "PV", 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) @@ -361,10 +350,6 @@ def epics_controller_api(class_mocker: MockerFixture): def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): util_builder = mocker.patch("fastcs.transports.epics.ca.util.builder") ioc_builder = mocker.patch("fastcs.transports.epics.ca.ioc.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" - ) EpicsCAIOC([epics_controller_api], {}) @@ -466,108 +451,6 @@ def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): ONAM="Active", ) - # Check info tags are added - add_pvi_info.assert_called_once_with(f"{DEVICE}:PVI") - add_sub_controller_pvi_info.assert_called_once_with(epics_controller_api) - - -def test_add_pvi_info(mocker: MockerFixture): - builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") - controller = mocker.MagicMock() - controller.path = [] - child = mocker.MagicMock() - child.path = ["Child"] - controller.get_sub_controllers.return_value = {"d": child} - - _add_pvi_info(f"{DEVICE}:PVI") - - builder.longStringIn.assert_called_once_with( - f"{DEVICE}:PVI_PV", - initial_value=f"{DEVICE}:PVI", - DESC="The records in this controller", - ) - record = builder.longStringIn.return_value - record.add_info.assert_called_once_with( - "Q:group", - { - f"{DEVICE}:PVI": { - "+id": "epics:nt/NTPVI:1.0", - "display.description": {"+type": "plain", "+channel": "DESC"}, - "": {"+type": "meta", "+channel": "VAL"}, - } - }, - ) - - -def test_add_pvi_info_with_parent(mocker: MockerFixture): - builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") - controller = mocker.MagicMock() - controller.path = [] - child = mocker.MagicMock() - child.path = ["Child"] - controller.get_sub_controllers.return_value = {"d": child} - - child = mocker.MagicMock() - _add_pvi_info(f"{DEVICE}:Child:PVI", f"{DEVICE}:PVI", "child") - - builder.longStringIn.assert_called_once_with( - f"{DEVICE}:Child:PVI_PV", - initial_value=f"{DEVICE}:Child:PVI", - DESC="The records in this controller", - ) - record = builder.longStringIn.return_value - record.add_info.assert_called_once_with( - "Q:group", - { - f"{DEVICE}:Child:PVI": { - "+id": "epics:nt/NTPVI:1.0", - "display.description": {"+type": "plain", "+channel": "DESC"}, - "": {"+type": "meta", "+channel": "VAL"}, - }, - f"{DEVICE}:PVI": { - "value.child.d": { - "+channel": "VAL", - "+type": "plain", - "+trigger": "value.child.d", - } - }, - }, - ) - - -def test_add_sub_controller_pvi_info(mocker: MockerFixture): - add_pvi_info = mocker.patch("fastcs.transports.epics.ca.ioc._add_pvi_info") - parent_api = mocker.MagicMock() - parent_api.path = [DEVICE] - child_api = mocker.MagicMock() - child_api.path = [DEVICE, "Child"] - parent_api.sub_apis = {"d": child_api} - - _add_sub_controller_pvi_info(parent_api) - - add_pvi_info.assert_called_once_with( - f"{DEVICE}:Child:PVI", f"{DEVICE}:PVI", "child" - ) - - -def test_add_attr_pvi_info(mocker: MockerFixture): - record = mocker.MagicMock() - - _add_attr_pvi_info(record, DEVICE, "attr", "r") - - record.add_info.assert_called_once_with( - "Q:group", - { - f"{DEVICE}:PVI": { - "value.attr.r": { - "+channel": "NAME", - "+type": "plain", - "+trigger": "value.attr.r", - } - } - }, - ) - async def do_nothing(): ... @@ -684,7 +567,7 @@ def test_non_1d_waveforms_discarded(mocker: MockerFixture): EpicsCAIOC([api], {}) create_mock.assert_called_once_with( - DEVICE, "Waveform1d", "waveform_1d", None, api.attributes["waveform_1d"] + DEVICE, "Waveform1d", None, api.attributes["waveform_1d"] ) diff --git a/tests/transports/epics/ca/test_softioc_system.py b/tests/transports/epics/ca/test_softioc_system.py index f898fe06..233e127c 100644 --- a/tests/transports/epics/ca/test_softioc_system.py +++ b/tests/transports/epics/ca/test_softioc_system.py @@ -1,60 +1,37 @@ +import asyncio from multiprocessing import Queue -from p4p import Value -from p4p.client.thread import Context +import pytest +from aioca import FORMAT_TIME, caget, camonitor, caput from softioc import alarm -def test_ioc(softioc_subprocess: tuple[str, Queue]): +@pytest.mark.asyncio +async def test_ioc(softioc_subprocess: tuple[str, Queue]): pv_prefix, _ = softioc_subprocess - ctxt = Context("pva") - _parent_pvi = ctxt.get(f"{pv_prefix}:PVI") - assert isinstance(_parent_pvi, Value) - parent_pvi = _parent_pvi.todict() - assert all(f in parent_pvi for f in ("alarm", "display", "timeStamp", "value")) - assert parent_pvi["display"] == {"description": "The records in this controller"} - assert parent_pvi["value"] == { - "a": {"r": f"{pv_prefix}:A"}, - "b": {"r": f"{pv_prefix}:B_RBV", "w": f"{pv_prefix}:B"}, - "childvector": {"d": f"{pv_prefix}:ChildVector:PVI"}, - } - - child_vector_pvi_pv = parent_pvi["value"]["childvector"]["d"] - _child_vector_pvi = ctxt.get(child_vector_pvi_pv) - assert isinstance(_child_vector_pvi, Value) - _child_vector_pvi = _child_vector_pvi.todict() - assert all( - f in _child_vector_pvi for f in ("alarm", "display", "timeStamp", "value") + # Assert alias + assert await caget(f"{pv_prefix}:B") == await caget(f"{pv_prefix}:AliasB") == 0 + await caput(f"{pv_prefix}:B", 10, wait=True) + assert await caget(f"{pv_prefix}:AliasB") == 10 + await caput(f"{pv_prefix}:AliasB", 20, wait=True) + assert await caget(f"{pv_prefix}:B") == 20 + b_rbv = await caget(f"{pv_prefix}:B_RBV") + alias_b_rbv = await caget(f"{pv_prefix}:AliasB_RBV") + assert b_rbv == alias_b_rbv == 20 + + # Assert command exceptions set record alarm states. The command record + # reverts back to False once the (failing) command completes. + d_values: asyncio.Queue = asyncio.Queue() + subscription = camonitor( + f"{pv_prefix}:ChildVector:0:D", d_values.put_nowait, format=FORMAT_TIME ) - assert _child_vector_pvi["display"] == { - "description": "The records in this controller" - } - assert _child_vector_pvi["value"] == { - "__0": {"d": f"{pv_prefix}:ChildVector:0:PVI"}, - "__1": {"d": f"{pv_prefix}:ChildVector:1:PVI"}, - } - - child_pvi_pv = _child_vector_pvi["value"]["__0"]["d"] - _child_pvi = ctxt.get(child_pvi_pv) - assert isinstance(_child_pvi, Value) - child_pvi = _child_pvi.todict() - assert all(f in child_pvi for f in ("alarm", "display", "timeStamp", "value")) - assert child_pvi["display"] == {"description": "The records in this controller"} - assert child_pvi["value"] == { - "c": {"w": f"{pv_prefix}:ChildVector:0:C"}, - "d": {"x": f"{pv_prefix}:ChildVector:0:D"}, - } - - # Assert alias. Aliases do not show up in PVI structure - assert ctxt.get(f"{pv_prefix}:B") == ctxt.get(f"{pv_prefix}:AliasB") == 0 - ctxt.put(f"{pv_prefix}:B", 10, wait=True) - assert ctxt.get(f"{pv_prefix}:AliasB") == 10 - ctxt.put(f"{pv_prefix}:AliasB", 20, wait=True) - assert ctxt.get(f"{pv_prefix}:B") == 20 - assert ctxt.get(f"{pv_prefix}:B_RBV") == ctxt.get(f"{pv_prefix}:AliasB_RBV") == 20 - - # Assert command exceptions set record alarm states - ctxt.put(f"{pv_prefix}:ChildVector:0:D", True, wait=True) - d_alarm = ctxt.get(f"{pv_prefix}:ChildVector:0:D.SEVR") - assert d_alarm == alarm.MAJOR_ALARM + try: + assert (await d_values.get()) == 0 # initial value + await caput(f"{pv_prefix}:ChildVector:0:D", True) + assert (await d_values.get()) == 1 # command starts running + d_value = await d_values.get() # command finishes and reverts to False + assert d_value.severity == alarm.MAJOR_ALARM + assert d_value == 0 + finally: + subscription.close() From 8ab5e522737867649c67a96f9cbdb586f63b0a2c Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Mon, 14 Sep 2026 11:53:11 +0000 Subject: [PATCH 3/3] tests: fix test checking command failure --- tests/transports/epics/ca/test_softioc_system.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/transports/epics/ca/test_softioc_system.py b/tests/transports/epics/ca/test_softioc_system.py index 233e127c..68d44a08 100644 --- a/tests/transports/epics/ca/test_softioc_system.py +++ b/tests/transports/epics/ca/test_softioc_system.py @@ -27,11 +27,12 @@ async def test_ioc(softioc_subprocess: tuple[str, Queue]): f"{pv_prefix}:ChildVector:0:D", d_values.put_nowait, format=FORMAT_TIME ) try: - assert (await d_values.get()) == 0 # initial value + assert await d_values.get() == 0 # First monitor value await caput(f"{pv_prefix}:ChildVector:0:D", True) - assert (await d_values.get()) == 1 # command starts running - d_value = await d_values.get() # command finishes and reverts to False - assert d_value.severity == alarm.MAJOR_ALARM - assert d_value == 0 + d_value = await d_values.get() + assert d_value.severity == alarm.MAJOR_ALARM # First real call fails + await caput(f"{pv_prefix}:ChildVector:0:D", True) + d_value = await d_values.get() + assert d_value.severity == alarm.NO_ALARM # Second real call succeeds finally: subscription.close()