From 80df6422bd41ea62e9285bc486f76ee6d28692e6 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Wed, 21 Aug 2024 10:26:15 +0000 Subject: [PATCH] Add info tags to define PVI structure from controllers Refactor helper functions to preserve PV prefix until later so that PVI PV can use it. Remove call to SetDeviceName and construct full PVs when creating records. Add call to create info tags on all records as they are created to add them to the PVI of the controller. Add call to add sub controller PVI to parent PVI --- src/fastcs/backend.py | 2 + src/fastcs/backends/epics/ioc.py | 185 +++++++++++++++++++++++++------ tests/backends/epics/test_ioc.py | 148 ++++++++++++++++++++++--- 3 files changed, 283 insertions(+), 52 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 48ada5b..80c8eab 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -1,6 +1,7 @@ import asyncio from collections import defaultdict from collections.abc import Callable +from concurrent.futures import Future from types import MethodType from softioc.asyncio_dispatcher import AsyncioDispatcher @@ -20,6 +21,7 @@ def __init__( self._controller = controller self._initial_tasks = [controller.connect] + self._scan_tasks: list[Future] = [] asyncio.run_coroutine_threadsafe( self._controller.initialise(), self._loop diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index ae86103..7c6fa0c 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -1,13 +1,14 @@ from collections.abc import Callable from dataclasses import dataclass from types import MethodType -from typing import Any +from typing import Any, Literal from softioc import builder, softioc from softioc.asyncio_dispatcher import AsyncioDispatcher from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.controller import BaseController from fastcs.datatypes import Bool, DataType, Float, Int, String from fastcs.exceptions import FastCSException from fastcs.mapping import Mapping @@ -20,10 +21,11 @@ class EpicsIOCOptions: class EpicsIOC: def __init__(self, pv_prefix: str, mapping: Mapping): - builder.SetDeviceName(pv_prefix) + _add_pvi_info(f"{pv_prefix}:PVI") + _add_sub_controller_pvi_info(pv_prefix, mapping.controller) - _create_and_link_attribute_pvs(mapping) - _create_and_link_command_pvs(mapping) + _create_and_link_attribute_pvs(pv_prefix, mapping) + _create_and_link_command_pvs(pv_prefix, mapping) def run( self, @@ -40,25 +42,95 @@ def run( softioc.interactive_ioc(context) -def _create_and_link_attribute_pvs(mapping: Mapping) -> None: +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(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(): - attr_name = attr_name.title().replace("_", "") - pv_name = f"{':'.join(path)}:{attr_name}" if path else attr_name + pv_name = attr_name.title().replace("_", "") + _pv_prefix = ":".join([pv_prefix] + path) match attribute: case AttrRW(): - _create_and_link_read_pv(pv_name + "_RBV", attribute) - _create_and_link_write_pv(pv_name, attribute) + _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_name, attribute) + _create_and_link_read_pv(_pv_prefix, pv_name, attr_name, attribute) case AttrW(): - _create_and_link_write_pv(pv_name, attribute) + _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 +) -> None: + record = _get_input_record(f"{pv_prefix}:{pv_name}", attribute.datatype) -def _create_and_link_read_pv(pv_name: str, attribute: AttrR) -> None: - record = _get_input_record(pv_name, attribute.datatype) + _add_attr_pvi_info(record, pv_prefix, attr_name, "r") async def async_wrapper(v): record.set(v) @@ -66,69 +138,112 @@ async def async_wrapper(v): attribute.set_update_callback(async_wrapper) -def _get_input_record(pv_name: str, datatype: DataType) -> RecordWrapper: +def _get_input_record(pv: str, datatype: DataType) -> RecordWrapper: match datatype: case Bool(znam, onam): - return builder.boolIn(pv_name, ZNAM=znam, ONAM=onam) + return builder.boolIn(pv, ZNAM=znam, ONAM=onam) case Int(): - return builder.longIn(pv_name) + return builder.longIn(pv) case Float(prec): - return builder.aIn(pv_name, PREC=prec) + return builder.aIn(pv, PREC=prec) case String(): - return builder.longStringIn(pv_name) + return builder.longStringIn(pv) case _: raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") -def _create_and_link_write_pv(pv_name: str, attribute: AttrW) -> None: +def _create_and_link_write_pv( + pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW +) -> None: record = _get_output_record( - pv_name, attribute.datatype, on_update=attribute.process_without_display_update + f"{pv_prefix}:{pv_name}", + attribute.datatype, + on_update=attribute.process_without_display_update, ) + _add_attr_pvi_info(record, pv_prefix, attr_name, "w") + async def async_wrapper(v): record.set(v, process=False) attribute.set_write_display_callback(async_wrapper) -def _get_output_record(pv_name: str, datatype: DataType, on_update: Callable) -> Any: +def _get_output_record(pv: str, datatype: DataType, on_update: Callable) -> Any: match datatype: case Bool(znam, onam): return builder.boolOut( - pv_name, + pv, ZNAM=znam, ONAM=onam, always_update=True, on_update=on_update, ) case Int(): - return builder.longOut(pv_name, always_update=True, on_update=on_update) + return builder.longOut(pv, always_update=True, on_update=on_update) case Float(prec): - return builder.aOut( - pv_name, always_update=True, on_update=on_update, PREC=prec - ) + return builder.aOut(pv, always_update=True, on_update=on_update, PREC=prec) case String(): - return builder.longStringOut( - pv_name, always_update=True, on_update=on_update - ) + return builder.longStringOut(pv, always_update=True, on_update=on_update) case _: raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") -def _create_and_link_command_pvs(mapping: Mapping) -> None: +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(): - attr_name = attr_name.title().replace("_", "") - pv_name = f"{':'.join(path)}:{attr_name}" if path else attr_name + pv_name = attr_name.title().replace("_", "") + _pv_prefix = ":".join([pv_prefix] + path) _create_and_link_command_pv( - pv_name, MethodType(method.fn, single_mapping.controller) + _pv_prefix, + pv_name, + attr_name, + MethodType(method.fn, single_mapping.controller), ) -def _create_and_link_command_pv(pv_name: str, method: Callable) -> None: +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() - builder.aOut(pv_name, initial_value=0, always_update=True, on_update=wrapped_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/tests/backends/epics/test_ioc.py b/tests/backends/epics/test_ioc.py index 3c474ec..8e6faf5 100644 --- a/tests/backends/epics/test_ioc.py +++ b/tests/backends/epics/test_ioc.py @@ -1,31 +1,145 @@ from pytest_mock import MockerFixture -from fastcs.backends.epics.ioc import EpicsIOC +from fastcs.backends.epics.ioc import ( + EpicsIOC, + _add_attr_pvi_info, + _add_pvi_info, + _add_sub_controller_pvi_info, +) from fastcs.mapping import Mapping def test_ioc(mocker: MockerFixture, mapping: Mapping): - builder_mock = mocker.patch("fastcs.backends.epics.ioc.builder") + 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" + ) EpicsIOC("DEVICE", mapping) - builder_mock.aIn.assert_called_once_with("ReadWriteFloat_RBV", PREC=2) - builder_mock.aOut.assert_any_call( - "ReadWriteFloat", always_update=True, on_update=mocker.ANY, PREC=2 + # Check records are created + builder.boolIn.assert_called_once_with("DEVICE:ReadBool", ZNAM="OFF", ONAM="ON") + builder.longIn.assert_any_call("DEVICE:ReadInt") + builder.aIn.assert_called_once_with("DEVICE:ReadWriteFloat_RBV", PREC=2) + builder.aOut.assert_any_call( + "DEVICE:ReadWriteFloat", always_update=True, on_update=mocker.ANY, PREC=2 + ) + builder.longIn.assert_any_call("DEVICE:ReadWriteInt_RBV") + builder.longOut.assert_called_with( + "DEVICE:ReadWriteInt", always_update=True, on_update=mocker.ANY + ) + builder.longStringIn.assert_called_once_with("DEVICE:StringEnum_RBV") + builder.longStringOut.assert_called_once_with( + "DEVICE:StringEnum", always_update=True, on_update=mocker.ANY ) - builder_mock.boolIn.assert_called_once_with("ReadBool", ZNAM="OFF", ONAM="ON") - builder_mock.boolOut.assert_called_once_with( - "WriteBool", ZNAM="OFF", ONAM="ON", always_update=True, on_update=mocker.ANY + builder.boolOut.assert_called_once_with( + "DEVICE:WriteBool", + ZNAM="OFF", + ONAM="ON", + always_update=True, + on_update=mocker.ANY, ) - builder_mock.longIn.assert_any_call("ReadInt") - builder_mock.longIn.assert_any_call("ReadWriteInt_RBV") - builder_mock.longOut.assert_called_with( - "ReadWriteInt", always_update=True, on_update=mocker.ANY + builder.aOut.assert_any_call( + "DEVICE:Go", initial_value=0, always_update=True, on_update=mocker.ANY + ) + + # Check info tags are added + add_pvi_info.assert_called_once_with("DEVICE:PVI") + add_sub_controller_pvi_info.assert_called_once_with("DEVICE", mapping.controller) + + +def test_add_pvi_info(mocker: MockerFixture): + builder = mocker.patch("fastcs.backends.epics.ioc.builder") + controller = mocker.MagicMock() + controller.path = [] + child = mocker.MagicMock() + child.path = ["Child"] + controller.get_sub_controllers.return_value = {"d": child} + + _add_pvi_info("DEVICE:PVI") + + builder.longStringIn.assert_called_once_with( + "DEVICE:PVI_PV", + initial_value="DEVICE:PVI", + DESC="The records in this controller", ) - builder_mock.longStringIn.assert_called_once_with("StringEnum_RBV") - builder_mock.longStringOut.assert_called_once_with( - "StringEnum", always_update=True, on_update=mocker.ANY + record = builder.longStringIn.return_value + record.add_info.assert_called_once_with( + "Q:group", + { + "DEVICE:PVI": { + "+id": "epics:nt/NTPVI:1.0", + "display.description": {"+type": "plain", "+channel": "DESC"}, + "": {"+type": "meta", "+channel": "VAL"}, + } + }, ) - builder_mock.aOut.assert_any_call( - "Go", initial_value=0, always_update=True, on_update=mocker.ANY + + +def test_add_pvi_info_with_parent(mocker: MockerFixture): + builder = mocker.patch("fastcs.backends.epics.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("DEVICE:Child:PVI", "DEVICE:PVI", "child") + + builder.longStringIn.assert_called_once_with( + "DEVICE:Child:PVI_PV", + initial_value="DEVICE:Child:PVI", + DESC="The records in this controller", + ) + record = builder.longStringIn.return_value + record.add_info.assert_called_once_with( + "Q:group", + { + "DEVICE:Child:PVI": { + "+id": "epics:nt/NTPVI:1.0", + "display.description": {"+type": "plain", "+channel": "DESC"}, + "": {"+type": "meta", "+channel": "VAL"}, + }, + "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.backends.epics.ioc._add_pvi_info") + controller = mocker.MagicMock() + controller.path = [] + child = mocker.MagicMock() + child.path = ["Child"] + controller.get_sub_controllers.return_value = {"d": child} + + _add_sub_controller_pvi_info("DEVICE", controller) + + add_pvi_info.assert_called_once_with("DEVICE:Child:PVI", "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", + { + "DEVICE:PVI": { + "value.attr.r": { + "+channel": "NAME", + "+type": "plain", + "+trigger": "value.attr.r", + } + } + }, )