diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py index 2172e41..8b96b81 100644 --- a/src/fastcs/datatypes.py +++ b/src/fastcs/datatypes.py @@ -11,7 +11,14 @@ from numpy.typing import DTypeLike T = TypeVar( - "T", int, float, bool, str, enum.Enum, np.ndarray, list[tuple[str, DTypeLike]] + "T", + int, # Int + float, # Float + bool, # Bool + str, # String + enum.Enum, # Enum + np.ndarray, # Waveform + list[tuple[str, DTypeLike]], # Table ) ATTRIBUTE_TYPES: tuple[type] = T.__constraints__ # type: ignore @@ -48,10 +55,10 @@ def initial_value(self) -> T: @dataclass(frozen=True) class _Numerical(DataType[T_Numerical]): units: str | None = None - min: float | None = None - max: float | None = None - min_alarm: float | None = None - max_alarm: float | None = None + min: T_Numerical | None = None + max: T_Numerical | None = None + min_alarm: T_Numerical | None = None + max_alarm: T_Numerical | None = None def validate(self, value: T_Numerical) -> T_Numerical: super().validate(value) diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index a3babd5..3bc2784 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -15,14 +15,15 @@ from .controller import Controller from .exceptions import LaunchError from .transport.adapter import TransportAdapter -from .transport.epics.options import EpicsBackend, EpicsOptions +from .transport.epics.ca.options import EpicsCAOptions +from .transport.epics.pva.options import EpicsPVAOptions from .transport.graphQL.options import GraphQLOptions from .transport.rest.options import RestOptions from .transport.tango.options import TangoOptions # Define a type alias for transport options TransportOptions: TypeAlias = list[ - EpicsOptions | TangoOptions | RestOptions | GraphQLOptions + EpicsPVAOptions | EpicsCAOptions | TangoOptions | RestOptions | GraphQLOptions ] @@ -39,23 +40,21 @@ def __init__( self._transports: list[TransportAdapter] = [] for option in transport_options: match option: - case EpicsOptions(backend=backend): - match backend: - case EpicsBackend.SOFT_IOC: - from .transport.epics.softioc.adapter import EpicsTransport - - transport = EpicsTransport( - controller, - self._loop, - option, - ) - case EpicsBackend.P4P: - from .transport.epics.p4p.adapter import P4PTransport - - transport = P4PTransport( - controller, - option, - ) + case EpicsPVAOptions(): + from .transport.epics.pva.adapter import EpicsPVATransport + + transport = EpicsPVATransport( + controller, + option, + ) + case EpicsCAOptions(): + from .transport.epics.ca.adapter import EpicsCATransport + + transport = EpicsCATransport( + controller, + self._loop, + option, + ) case TangoOptions(): from .transport.tango.adapter import TangoTransport diff --git a/src/fastcs/transport/__init__.py b/src/fastcs/transport/__init__.py index 5efce3f..b9c0d09 100644 --- a/src/fastcs/transport/__init__.py +++ b/src/fastcs/transport/__init__.py @@ -1,8 +1,8 @@ -from .epics.options import EpicsBackend as EpicsBackend +from .epics.ca.options import EpicsCAOptions as EpicsCAOptions from .epics.options import EpicsDocsOptions as EpicsDocsOptions from .epics.options import EpicsGUIOptions as EpicsGUIOptions from .epics.options import EpicsIOCOptions as EpicsIOCOptions -from .epics.options import EpicsOptions as EpicsOptions +from .epics.pva.options import EpicsPVAOptions as EpicsPVAOptions from .graphQL.options import GraphQLOptions as GraphQLOptions from .graphQL.options import GraphQLServerOptions as GraphQLServerOptions from .rest.options import RestOptions as RestOptions diff --git a/src/fastcs/transport/epics/p4p/__init__.py b/src/fastcs/transport/epics/ca/__init__.py similarity index 100% rename from src/fastcs/transport/epics/p4p/__init__.py rename to src/fastcs/transport/epics/ca/__init__.py diff --git a/src/fastcs/transport/epics/softioc/adapter.py b/src/fastcs/transport/epics/ca/adapter.py similarity index 77% rename from src/fastcs/transport/epics/softioc/adapter.py rename to src/fastcs/transport/epics/ca/adapter.py index b8f31c9..090b8b2 100644 --- a/src/fastcs/transport/epics/softioc/adapter.py +++ b/src/fastcs/transport/epics/ca/adapter.py @@ -2,22 +2,22 @@ from fastcs.controller import Controller from fastcs.transport.adapter import TransportAdapter +from fastcs.transport.epics.ca.ioc import EpicsIOC +from fastcs.transport.epics.ca.options import EpicsCAOptions from fastcs.transport.epics.docs import EpicsDocs from fastcs.transport.epics.gui import EpicsGUI -from fastcs.transport.epics.options import EpicsOptions -from fastcs.transport.epics.softioc.ioc import EpicsIOC -class EpicsTransport(TransportAdapter): +class EpicsCATransport(TransportAdapter): def __init__( self, controller: Controller, loop: asyncio.AbstractEventLoop, - options: EpicsOptions | None = None, + options: EpicsCAOptions | None = None, ) -> None: self._controller = controller self._loop = loop - self._options = options or EpicsOptions() + self._options = options or EpicsCAOptions() self._pv_prefix = self.options.ioc.pv_prefix self._ioc = EpicsIOC( self.options.ioc.pv_prefix, @@ -26,7 +26,7 @@ def __init__( ) @property - def options(self) -> EpicsOptions: + def options(self) -> EpicsCAOptions: return self._options def create_docs(self) -> None: diff --git a/src/fastcs/transport/epics/softioc/ioc.py b/src/fastcs/transport/epics/ca/ioc.py similarity index 99% rename from src/fastcs/transport/epics/softioc/ioc.py rename to src/fastcs/transport/epics/ca/ioc.py index bd276ce..7bee56e 100644 --- a/src/fastcs/transport/epics/softioc/ioc.py +++ b/src/fastcs/transport/epics/ca/ioc.py @@ -10,14 +10,14 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controller import BaseController, Controller from fastcs.datatypes import DataType, T -from fastcs.transport.epics.options import EpicsIOCOptions -from fastcs.transport.epics.softioc.util import ( +from fastcs.transport.epics.ca.util import ( builder_callable_from_attribute, cast_from_epics_type, cast_to_epics_type, record_metadata_from_attribute, record_metadata_from_datatype, ) +from fastcs.transport.epics.options import EpicsIOCOptions EPICS_MAX_NAME_LENGTH = 60 diff --git a/src/fastcs/transport/epics/ca/options.py b/src/fastcs/transport/epics/ca/options.py new file mode 100644 index 0000000..547a351 --- /dev/null +++ b/src/fastcs/transport/epics/ca/options.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass, field + +from ..options import ( + EpicsDocsOptions, + EpicsGUIOptions, + EpicsIOCOptions, +) + + +@dataclass +class EpicsCAOptions: + docs: EpicsDocsOptions = field(default_factory=EpicsDocsOptions) + gui: EpicsGUIOptions = field(default_factory=EpicsGUIOptions) + ioc: EpicsIOCOptions = field(default_factory=EpicsIOCOptions) diff --git a/src/fastcs/transport/epics/softioc/util.py b/src/fastcs/transport/epics/ca/util.py similarity index 100% rename from src/fastcs/transport/epics/softioc/util.py rename to src/fastcs/transport/epics/ca/util.py diff --git a/src/fastcs/transport/epics/options.py b/src/fastcs/transport/epics/options.py index dc5ed48..c42177c 100644 --- a/src/fastcs/transport/epics/options.py +++ b/src/fastcs/transport/epics/options.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -24,16 +24,3 @@ class EpicsGUIOptions: @dataclass class EpicsIOCOptions: pv_prefix: str = "MY-DEVICE-PREFIX" - - -class EpicsBackend(Enum): - SOFT_IOC = "softioc" - P4P = "p4p" - - -@dataclass -class EpicsOptions: - docs: EpicsDocsOptions = field(default_factory=EpicsDocsOptions) - gui: EpicsGUIOptions = field(default_factory=EpicsGUIOptions) - ioc: EpicsIOCOptions = field(default_factory=EpicsIOCOptions) - backend: EpicsBackend = EpicsBackend.SOFT_IOC diff --git a/src/fastcs/transport/epics/p4p/pvi_tree.py b/src/fastcs/transport/epics/p4p/pvi_tree.py deleted file mode 100644 index bf81d8f..0000000 --- a/src/fastcs/transport/epics/p4p/pvi_tree.py +++ /dev/null @@ -1,197 +0,0 @@ -import re -from dataclasses import dataclass -from typing import Literal - -from p4p import Type, Value -from p4p.nt.common import alarm, timeStamp -from p4p.server import StaticProvider -from p4p.server.asyncio import SharedPV - -from fastcs.controller import BaseController - -from .types import p4p_alarm_states, p4p_timestamp_now - -AccessModeType = Literal["r", "w", "rw", "d", "x"] - -PviName = str - - -@dataclass -class _PviFieldInfo: - pv: str - access: AccessModeType - - # Controller type to check all pvi "d" in a group are the same type. - controller_t: type[BaseController] | None - - # Number for the int value on the end of the pv, - # corresponding to `v` in the structure. - number: int | None = None - - -@dataclass -class _PviBlockInfo: - field_infos: dict[str, list[_PviFieldInfo]] - description: str | None - - -def _camel_to_snake(name: str) -> str: - s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) - return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() - - -def _pv_to_pvi_field(pv: str) -> tuple[str, int | None]: - leaf = pv.rsplit(":", maxsplit=1)[-1] - match = re.search(r"(\d+)$", leaf) - number = int(match.group(1)) if match else None - string_without_number = re.sub(r"\d+$", "", leaf) - return _camel_to_snake(string_without_number), number - - -# TODO: This can be dramatically cleaned up after https://github.com/DiamondLightSource/FastCS/issues/122 - - -class PviTree: - def __init__(self): - self._pvi_info: dict[PviName, _PviBlockInfo] = {} - - def add_block( - self, - block_pv: str, - description: str | None, - controller_t: type[BaseController] | None = None, - ): - pvi_name, number = _pv_to_pvi_field(block_pv) - if block_pv not in self._pvi_info: - self._pvi_info[block_pv] = _PviBlockInfo( - field_infos={}, description=description - ) - - parent_block_pv = block_pv.rsplit(":", maxsplit=1)[0] - - if parent_block_pv == block_pv: - return - - if pvi_name not in self._pvi_info[parent_block_pv].field_infos: - self._pvi_info[parent_block_pv].field_infos[pvi_name] = [] - elif ( - controller_t - is not ( - other_field := self._pvi_info[parent_block_pv].field_infos[pvi_name][-1] - ).controller_t - ): - raise ValueError( - f"Can't add `{block_pv}` to pvi group {pvi_name}. " - f"It represents a {controller_t}, however {other_field.pv} " - f"represents a {other_field.controller_t}." - ) - - self._pvi_info[parent_block_pv].field_infos[pvi_name].append( - _PviFieldInfo( - pv=f"{block_pv}:PVI", - access="d", - controller_t=controller_t, - number=number, - ) - ) - - def add_field( - self, - attribute_pv: str, - access: AccessModeType, - ): - pvi_name, number = _pv_to_pvi_field(attribute_pv) - parent_block_pv = attribute_pv.rsplit(":", maxsplit=1)[0] - - if pvi_name not in self._pvi_info[parent_block_pv].field_infos: - self._pvi_info[parent_block_pv].field_infos[pvi_name] = [] - - self._pvi_info[parent_block_pv].field_infos[pvi_name].append( - _PviFieldInfo( - pv=attribute_pv, access=access, controller_t=None, number=number - ) - ) - - def make_provider(self) -> StaticProvider: - provider = StaticProvider("PVI") - - for block_pv, block_info in self._pvi_info.items(): - provider.add( - f"{block_pv}:PVI", - SharedPV(initial=self._p4p_value(block_info)), - ) - return provider - - def _p4p_value(self, block_info: _PviBlockInfo) -> Value: - pvi_structure = [] - for pvi_name, field_infos in block_info.field_infos.items(): - if len(field_infos) == 1: - field_datatype = [(field_infos[0].access, "s")] - else: - assert all( - field_info.access == field_infos[0].access - for field_info in field_infos - ) - field_datatype = [ - ( - field_infos[0].access, - ( - "S", - None, - [ - (f"v{field_info.number}", "s") - for field_info in field_infos - ], - ), - ) - ] - - substructure = ( - pvi_name, - ( - "S", - "structure", - # If there are multiple field_infos then they ar the same type of - # controller. - field_datatype, - ), - ) - pvi_structure.append(substructure) - - p4p_type = Type( - [ - ("alarm", alarm), - ("timeStamp", timeStamp), - ("display", ("S", None, [("description", "s")])), - ("value", ("S", "structure", tuple(pvi_structure))), - ] - ) - - value = {} - for pvi_name, field_infos in block_info.field_infos.items(): - if len(field_infos) == 1: - value[pvi_name] = {field_infos[0].access: field_infos[0].pv} - else: - value[pvi_name] = { - field_infos[0].access: { - f"v{field_info.number}": field_info.pv - for field_info in field_infos - } - } - - # Done here so the value can be (none) if block_info.description isn't defined. - display = ( - {"display": {"description": block_info.description}} - if block_info.description - else {} - ) - - return Value( - p4p_type, - { - **p4p_alarm_states(), - **p4p_timestamp_now(), - **display, - "value": value, - }, - ) diff --git a/src/fastcs/transport/epics/softioc/__init__.py b/src/fastcs/transport/epics/pva/__init__.py similarity index 100% rename from src/fastcs/transport/epics/softioc/__init__.py rename to src/fastcs/transport/epics/pva/__init__.py diff --git a/src/fastcs/transport/epics/p4p/handlers.py b/src/fastcs/transport/epics/pva/_pv_handlers.py similarity index 78% rename from src/fastcs/transport/epics/p4p/handlers.py rename to src/fastcs/transport/epics/pva/_pv_handlers.py index ed703fb..242e68a 100644 --- a/src/fastcs/transport/epics/p4p/handlers.py +++ b/src/fastcs/transport/epics/pva/_pv_handlers.py @@ -3,7 +3,8 @@ from collections.abc import Callable import numpy as np -from p4p.nt import NTScalar +from p4p import Value +from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable from p4p.server import ServerOperation from p4p.server.asyncio import SharedPV @@ -15,25 +16,27 @@ RECORD_ALARM_STATUS, cast_from_p4p_value, cast_to_p4p_value, - get_p4p_type, + make_p4p_type, p4p_alarm_states, ) -class AttrWHandler: +class WritePvHandler: def __init__(self, attr_w: AttrW | AttrRW): self._attr_w = attr_w async def put(self, pv: SharedPV, op: ServerOperation): value = op.value() - if isinstance(value, list): - assert isinstance(self._attr_w.datatype, Table) + if isinstance(self._attr_w.datatype, Table): + assert isinstance(value, list) raw_value = np.array( [tuple(labelled_row.values()) for labelled_row in value], dtype=self._attr_w.datatype.structured_dtype, ) - else: + elif hasattr(value, "raw"): raw_value = value.raw.value + else: + raw_value = value.todict()["value"] cast_value = cast_from_p4p_value(self._attr_w, raw_value) @@ -44,7 +47,7 @@ async def put(self, pv: SharedPV, op: ServerOperation): op.done() -class CommandHandler: +class CommandPvHandler: def __init__(self, command: Callable): self._command = command self._task_started_event = asyncio.Event() @@ -88,17 +91,24 @@ def make_shared_pv(attribute: Attribute) -> SharedPV: if isinstance(attribute, AttrRW | AttrR) else attribute.datatype.initial_value ) - kwargs = { - "nt": get_p4p_type(attribute), - "initial": cast_to_p4p_value(attribute, initial_value), - } - if isinstance(attribute, (AttrW | AttrRW)): - kwargs["handler"] = AttrWHandler(attribute) + type_ = make_p4p_type(attribute) + kwargs = {"initial": cast_to_p4p_value(attribute, initial_value)} + if isinstance(type_, (NTEnum | NTNDArray | NTTable)): + kwargs["nt"] = type_ + else: + + def _wrap(value: dict): + return Value(type_, value) + + kwargs["wrap"] = _wrap + + if isinstance(attribute, AttrW): + kwargs["handler"] = WritePvHandler(attribute) shared_pv = SharedPV(**kwargs) - if isinstance(attribute, (AttrR | AttrRW)): + if isinstance(attribute, AttrR): shared_pv.post(cast_to_p4p_value(attribute, attribute.get())) async def on_update(value): @@ -113,7 +123,7 @@ def make_command_pv(command: Callable) -> SharedPV: shared_pv = SharedPV( nt=NTScalar("?"), initial=False, - handler=CommandHandler(command), + handler=CommandPvHandler(command), ) return shared_pv diff --git a/src/fastcs/transport/epics/p4p/adapter.py b/src/fastcs/transport/epics/pva/adapter.py similarity index 77% rename from src/fastcs/transport/epics/p4p/adapter.py rename to src/fastcs/transport/epics/pva/adapter.py index fafb6c8..c50ab01 100644 --- a/src/fastcs/transport/epics/p4p/adapter.py +++ b/src/fastcs/transport/epics/pva/adapter.py @@ -2,24 +2,24 @@ from fastcs.transport.adapter import TransportAdapter from fastcs.transport.epics.docs import EpicsDocs from fastcs.transport.epics.gui import EpicsGUI -from fastcs.transport.epics.options import EpicsOptions +from fastcs.transport.epics.pva.options import EpicsPVAOptions from .ioc import P4PIOC -class P4PTransport(TransportAdapter): +class EpicsPVATransport(TransportAdapter): def __init__( self, controller: Controller, - options: EpicsOptions | None = None, + options: EpicsPVAOptions | None = None, ) -> None: self._controller = controller - self._options = options or EpicsOptions() + self._options = options or EpicsPVAOptions() self._pv_prefix = self.options.ioc.pv_prefix self._ioc = P4PIOC(self.options.ioc.pv_prefix, controller) @property - def options(self) -> EpicsOptions: + def options(self) -> EpicsPVAOptions: return self._options async def serve(self) -> None: diff --git a/src/fastcs/transport/epics/p4p/ioc.py b/src/fastcs/transport/epics/pva/ioc.py similarity index 62% rename from src/fastcs/transport/epics/p4p/ioc.py rename to src/fastcs/transport/epics/pva/ioc.py index 06ef44c..70cad6a 100644 --- a/src/fastcs/transport/epics/p4p/ioc.py +++ b/src/fastcs/transport/epics/pva/ioc.py @@ -1,4 +1,5 @@ import asyncio +import re from types import MethodType from p4p.server import Server, StaticProvider @@ -6,48 +7,56 @@ from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controller import Controller -from .handlers import make_command_pv, make_shared_pv +from ._pv_handlers import make_command_pv, make_shared_pv from .pvi_tree import AccessModeType, PviTree -_attr_to_access: dict[type[Attribute], AccessModeType] = { - AttrR: "r", - AttrW: "w", - AttrRW: "rw", -} +def _attribute_to_access(attribute: Attribute) -> AccessModeType: + match attribute: + case AttrRW(): + return "rw" + case AttrR(): + return "r" + case AttrW(): + return "w" + case _: + raise ValueError(f"Unknown attribute type {type(attribute)}") -def get_pv_name(pv_prefix: str, attribute_name: str) -> str: - return f"{pv_prefix}:{attribute_name.title().replace('_', '')}" + +def _snake_to_pascal(name: str) -> str: + name = re.sub( + r"(?:^|_)([a-z])", lambda match: match.group(1).upper(), name + ).replace("_", "") + return re.sub(r"_(\d+)$", r"\1", name) + + +def get_pv_name(pv_prefix: str, *attribute_names: str) -> str: + pv_formatted = ":".join([_snake_to_pascal(attr) for attr in attribute_names]) + return f"{pv_prefix}:{pv_formatted}" if pv_formatted else pv_prefix async def parse_attributes( - prefix_root: str, controller: Controller + root_pv_prefix: str, controller: Controller ) -> list[StaticProvider]: providers = [] - pvi_tree = PviTree() - pvi_tree.add_block( - prefix_root, - controller.description, - type(controller), - ) + pvi_tree = PviTree(root_pv_prefix) for single_mapping in controller.get_controller_mappings(): path = single_mapping.controller.path - pv_prefix = ":".join([prefix_root] + path) + pv_prefix = get_pv_name(root_pv_prefix, *path) provider = StaticProvider(pv_prefix) providers.append(provider) pvi_tree.add_block( pv_prefix, single_mapping.controller.description, - type(single_mapping.controller), ) for attr_name, attribute in single_mapping.attributes.items(): pv_name = get_pv_name(pv_prefix, attr_name) attribute_pv = make_shared_pv(attribute) provider.add(pv_name, attribute_pv) - pvi_tree.add_field(pv_name, _attr_to_access[type(attribute)]) + pvi_tree.add_field(pv_name, _attribute_to_access(attribute)) for attr_name, method in single_mapping.command_methods.items(): pv_name = get_pv_name(pv_prefix, attr_name) diff --git a/src/fastcs/transport/epics/pva/options.py b/src/fastcs/transport/epics/pva/options.py new file mode 100644 index 0000000..e9ee9fb --- /dev/null +++ b/src/fastcs/transport/epics/pva/options.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass, field + +from ..options import ( + EpicsDocsOptions, + EpicsGUIOptions, + EpicsIOCOptions, +) + + +@dataclass +class EpicsPVAOptions: + docs: EpicsDocsOptions = field(default_factory=EpicsDocsOptions) + gui: EpicsGUIOptions = field(default_factory=EpicsGUIOptions) + ioc: EpicsIOCOptions = field(default_factory=EpicsIOCOptions) diff --git a/src/fastcs/transport/epics/pva/pvi_tree.py b/src/fastcs/transport/epics/pva/pvi_tree.py new file mode 100644 index 0000000..0c3c01d --- /dev/null +++ b/src/fastcs/transport/epics/pva/pvi_tree.py @@ -0,0 +1,191 @@ +import re +from collections import defaultdict +from dataclasses import dataclass +from typing import Literal + +from p4p import Type, Value +from p4p.nt.common import alarm, timeStamp +from p4p.server import StaticProvider +from p4p.server.asyncio import SharedPV + +from .types import p4p_alarm_states, p4p_timestamp_now + +AccessModeType = Literal["r", "w", "rw", "d", "x"] + +PviName = str + + +@dataclass +class _PviFieldInfo: + pv: str + access: AccessModeType + + +def _pascal_to_snake(name: str) -> str: + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() + + +def _pv_to_pvi_name(pv: str) -> tuple[str, int | None]: + leaf = pv.rsplit(":", maxsplit=1)[-1] + match = re.search(r"(\d+)$", leaf) + number = int(match.group(1)) if match else None + string_without_number = re.sub(r"\d+$", "", leaf) + return _pascal_to_snake(string_without_number), number + + +class PviBlock(dict[str, "PviBlock"]): + pv_prefix: str + description: str | None + block_field_info: _PviFieldInfo | None + + def __init__( + self, + pv_prefix: str, + description: str | None = None, + block_field_info: _PviFieldInfo | None = None, + ): + self.pv_prefix = pv_prefix + self.description = description + self.block_field_info = block_field_info + + def __missing__(self, key: str) -> "PviBlock": + new_block = PviBlock(pv_prefix=f"{self.pv_prefix}:{key}") + self[key] = new_block + return self[key] + + def get_recursively(self, *args: str) -> "PviBlock": + d = self + for arg in args: + d = d[arg] + return d + + def _get_field_infos(self) -> dict[str, _PviFieldInfo]: + block_field_infos: dict[str, _PviFieldInfo] = {} + + for sub_block_name, sub_block in self.items(): + if sub_block: + block_field_infos[f"{sub_block_name}:PVI"] = _PviFieldInfo( + pv=f"{sub_block.pv_prefix}:PVI", access="d" + ) + if sub_block.block_field_info: + block_field_infos[sub_block_name] = sub_block.block_field_info + + return block_field_infos + + def _make_p4p_raw_value(self) -> dict: + p4p_raw_value = defaultdict(dict) + for pv_leaf, field_info in self._get_field_infos().items(): + pvi_name, number = _pv_to_pvi_name(pv_leaf.rstrip(":PVI") or pv_leaf) + if number is not None: + if field_info.access not in p4p_raw_value[pvi_name]: + p4p_raw_value[pvi_name][field_info.access] = {} + p4p_raw_value[pvi_name][field_info.access][f"v{number}"] = field_info.pv + else: + p4p_raw_value[pvi_name][field_info.access] = field_info.pv + + return p4p_raw_value + + def _make_type_for_raw_value(self, raw_value: dict) -> Type: + p4p_raw_type = [] + for pvi_group_name, access_to_field in raw_value.items(): + pvi_group_structure = [] + for access, field in access_to_field.items(): + if isinstance(field, str): + pvi_group_structure.append((access, "s")) + elif isinstance(field, dict): + pvi_group_structure.append( + ( + access, + ( + "S", + None, + [(v, "s") for v, _ in field.items()], + ), + ) + ) + + p4p_raw_type.append( + (pvi_group_name, ("S", "structure", pvi_group_structure)) + ) + + return Type( + [ + ("alarm", alarm), + ("timeStamp", timeStamp), + ("display", ("S", None, [("description", "s")])), + ("value", ("S", "structure", p4p_raw_type)), + ] + ) + + def make_p4p_value(self) -> Value: + display = ( + {"display": {"description": self.description}} + if self.description is not None + else {} + ) # Defined here so the value can be (none) + + raw_value = self._make_p4p_raw_value() + p4p_type = self._make_type_for_raw_value(raw_value) + + return Value( + p4p_type, + { + **p4p_alarm_states(), + **p4p_timestamp_now(), + **display, + "value": raw_value, + }, + ) + + def make_provider( + self, + provider: StaticProvider | None = None, + ) -> StaticProvider: + if provider is None: + provider = StaticProvider("PVI") + + provider.add( + f"{self.pv_prefix}:PVI", + SharedPV(initial=self.make_p4p_value()), + ) + + for sub_block in self.values(): + if sub_block: + sub_block.make_provider(provider=provider) + + return provider + + +# TODO: This can be dramatically cleaned up after https://github.com/DiamondLightSource/FastCS/issues/122 +class PviTree: + def __init__(self, pv_prefix: str): + self._pvi_tree_root: PviBlock = PviBlock(pv_prefix) + + def add_block( + self, + block_pv: str, + description: str | None, + ): + if ":" not in block_pv: + assert block_pv == self._pvi_tree_root.pv_prefix + self._pvi_tree_root.description = description + else: + self._pvi_tree_root.get_recursively( + *block_pv.split(":")[1:] # To remove the prefix + ).description = description + + def add_field( + self, + attribute_pv: str, + access: AccessModeType, + ): + leaf_block = self._pvi_tree_root.get_recursively(*attribute_pv.split(":")[1:]) + + if leaf_block.block_field_info is not None: + raise ValueError(f"Tried to add the field '{attribute_pv}' twice.") + + leaf_block.block_field_info = _PviFieldInfo(pv=attribute_pv, access=access) + + def make_provider(self) -> StaticProvider: + return self._pvi_tree_root.make_provider() diff --git a/src/fastcs/transport/epics/p4p/types.py b/src/fastcs/transport/epics/pva/types.py similarity index 63% rename from src/fastcs/transport/epics/p4p/types.py rename to src/fastcs/transport/epics/pva/types.py index 8503753..94f1c80 100644 --- a/src/fastcs/transport/epics/p4p/types.py +++ b/src/fastcs/transport/epics/pva/types.py @@ -1,35 +1,16 @@ import math import time -from dataclasses import asdict import numpy as np from numpy.typing import DTypeLike +from p4p import Value from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable -from fastcs.attributes import Attribute +from fastcs.attributes import Attribute, AttrR, AttrW from fastcs.datatypes import Bool, Enum, Float, Int, String, T, Table, Waveform P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform, Table) - -_P4P_EXTRA = [("description", ("u", None, [("defval", "s")]))] -_P4P_BOOL = NTScalar("?", extra=_P4P_EXTRA) -_P4P_STRING = NTScalar("s", extra=_P4P_EXTRA) - - -_P4P_EXTRA_NUMERICAL = [ - ("units", ("u", None, [("defval", "s")])), - ("min", ("u", None, [("defval", "d")])), - ("max", ("u", None, [("defval", "d")])), - ("min_alarm", ("u", None, [("defval", "d")])), - ("max_alarm", ("u", None, [("defval", "d")])), -] -_P4P_INT = NTScalar("i", extra=_P4P_EXTRA + _P4P_EXTRA_NUMERICAL) - -_P4P_EXTRA_FLOAT = [("prec", ("u", None, [("defval", "i")]))] -_P4P_FLOAT = NTScalar("d", extra=_P4P_EXTRA + _P4P_EXTRA_NUMERICAL + _P4P_EXTRA_FLOAT) - - # https://epics-base.github.io/pvxs/nt.html#alarm-t RECORD_ALARM_STATUS = 3 NO_ALARM_STATUS = 0 @@ -45,31 +26,42 @@ def _table_with_numpy_dtypes_to_p4p_dtypes(numpy_dtypes: list[tuple[str, DTypeLike]]): + """ + Numpy structured datatypes can use the numpy dtype class, e.g `np.int32` or the + character, e.g "i". P4P only accepts the character so this method is used to + convert. + + https://epics-base.github.io/p4p/values.html#type-definitions + + It also forbids: + The numpy dtype for float16, which isn't supported in p4p. + String types which should be supported but currently don't function: + https://github.com/epics-base/p4p/issues/168 + """ p4p_dtypes = [] for name, numpy_dtype in numpy_dtypes: dtype_char = np.dtype(numpy_dtype).char dtype_char = _NUMPY_DTYPE_TO_P4P_DTYPE.get(dtype_char, dtype_char) - if dtype_char in ("e", "h", "H"): - raise ValueError( - "Table has a 16 bit numpy datatype. " - "Not supported in p4p, use 32 or 64 instead." - ) + if dtype_char in ("e", "U", "S"): + raise ValueError(f"`{np.dtype(numpy_dtype)}` is unsupported in p4p.") p4p_dtypes.append((name, dtype_char)) return p4p_dtypes -def get_p4p_type( +def make_p4p_type( attribute: Attribute, ) -> NTScalar | NTEnum | NTNDArray | NTTable: + display = isinstance(attribute, AttrR) + control = isinstance(attribute, AttrW) match attribute.datatype: case Int(): - return _P4P_INT + return NTScalar.buildType("i", display=display, control=control) case Float(): - return _P4P_FLOAT + return NTScalar.buildType("d", display=display, control=control, form=True) case String(): - return _P4P_STRING + return NTScalar.buildType("s", display=display, control=control) case Bool(): - return _P4P_BOOL + return NTScalar.buildType("?", display=display, control=control) case Enum(): return NTEnum() case Waveform(): @@ -137,21 +129,45 @@ def p4p_timestamp_now() -> dict: } -def _p4p_check_numerical_for_alarm_states( - min_alarm: float | None, max_alarm: float | None, value: T -) -> dict: - low = None if min_alarm is None else value < min_alarm # type: ignore - high = None if max_alarm is None else value > max_alarm # type: ignore +def p4p_display(attribute: Attribute) -> dict: + display = {} + if attribute.description is not None: + display["description"] = attribute.description + if isinstance(attribute.datatype, (Float | Int)): + if attribute.datatype.max is not None: + display["limitHigh"] = attribute.datatype.max + if attribute.datatype.min is not None: + display["limitLow"] = attribute.datatype.min + if attribute.datatype.units is not None: + display["units"] = attribute.datatype.units + if isinstance(attribute.datatype, Float): + if attribute.datatype.prec is not None: + display["precision"] = attribute.datatype.prec + if display: + return {"display": display} + return {} + + +def _p4p_check_numerical_for_alarm_states(datatype: Int | Float, value: T) -> dict: + low = None if datatype.min_alarm is None else value < datatype.min_alarm # type: ignore + high = None if datatype.max_alarm is None else value > datatype.max_alarm # type: ignore severity = ( MAJOR_ALARM_SEVERITY if high not in (None, False) or low not in (None, False) else NO_ALARM_SEVERITY ) - status, message = NO_ALARM_SEVERITY, "No alarm." + status, message = NO_ALARM_SEVERITY, "No alarm" if low: - status, message = RECORD_ALARM_STATUS, "Below minimum." + status, message = ( + RECORD_ALARM_STATUS, + f"Below minimum alarm limit: {datatype.min_alarm}", + ) if high: - status, message = RECORD_ALARM_STATUS, "Above maximum." + status, message = ( + RECORD_ALARM_STATUS, + f"Above maximum alarm limit: {datatype.max_alarm}", + ) + return p4p_alarm_states(severity, status, message) @@ -168,24 +184,22 @@ def cast_to_p4p_value(attribute: Attribute[T], value: T) -> object: return attribute.datatype.validate(value) case datatype if issubclass(type(datatype), P4P_ALLOWED_DATATYPES): - record_fields = {"value": datatype.validate(value)} - if attribute.description is not None: - record_fields["description"] = attribute.description # type: ignore + record_fields: dict = {"value": datatype.validate(value)} + if isinstance(attribute, AttrR): + record_fields.update(p4p_display(attribute)) + if isinstance(datatype, (Float | Int)): record_fields.update( _p4p_check_numerical_for_alarm_states( - datatype.min_alarm, - datatype.max_alarm, + datatype, value, ) ) else: record_fields.update(p4p_alarm_states()) - record_fields.update( - {k: v for k, v in asdict(datatype).items() if v is not None} - ) record_fields.update(p4p_timestamp_now()) - return get_p4p_type(attribute).wrap(record_fields) # type: ignore + + return Value(make_p4p_type(attribute), record_fields) case _: raise ValueError(f"Unsupported datatype {attribute.datatype}") diff --git a/tests/benchmarking/controller.py b/tests/benchmarking/controller.py index 4b874b5..cb1c895 100644 --- a/tests/benchmarking/controller.py +++ b/tests/benchmarking/controller.py @@ -2,7 +2,8 @@ from fastcs.attributes import AttrR, AttrW from fastcs.controller import Controller from fastcs.datatypes import Bool, Int -from fastcs.transport.epics.options import EpicsBackend, EpicsIOCOptions, EpicsOptions +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions from fastcs.transport.rest.options import RestOptions, RestServerOptions from fastcs.transport.tango.options import TangoDSROptions, TangoOptions @@ -15,9 +16,8 @@ class TestController(Controller): def run(): transport_options = [ RestOptions(rest=RestServerOptions(port=8090)), - EpicsOptions( + EpicsCAOptions( ioc=EpicsIOCOptions(pv_prefix="BENCHMARK-DEVICE"), - backend=EpicsBackend.SOFT_IOC, ), TangoOptions(dsr=TangoDSROptions(dev_name="MY/BENCHMARK/DEVICE")), ] diff --git a/tests/data/schema.json b/tests/data/schema.json index 4d5398f..d3dc5bc 100644 --- a/tests/data/schema.json +++ b/tests/data/schema.json @@ -1,12 +1,19 @@ { "$defs": { - "EpicsBackend": { - "enum": [ - "softioc", - "p4p" - ], - "title": "EpicsBackend", - "type": "string" + "EpicsCAOptions": { + "properties": { + "docs": { + "$ref": "#/$defs/EpicsDocsOptions" + }, + "gui": { + "$ref": "#/$defs/EpicsGUIOptions" + }, + "ioc": { + "$ref": "#/$defs/EpicsIOCOptions" + } + }, + "title": "EpicsCAOptions", + "type": "object" }, "EpicsDocsOptions": { "properties": { @@ -72,7 +79,7 @@ "title": "EpicsIOCOptions", "type": "object" }, - "EpicsOptions": { + "EpicsPVAOptions": { "properties": { "docs": { "$ref": "#/$defs/EpicsDocsOptions" @@ -82,13 +89,9 @@ }, "ioc": { "$ref": "#/$defs/EpicsIOCOptions" - }, - "backend": { - "$ref": "#/$defs/EpicsBackend", - "default": "softioc" } }, - "title": "EpicsOptions", + "title": "EpicsPVAOptions", "type": "object" }, "GraphQLOptions": { @@ -204,7 +207,10 @@ "items": { "anyOf": [ { - "$ref": "#/$defs/EpicsOptions" + "$ref": "#/$defs/EpicsPVAOptions" + }, + { + "$ref": "#/$defs/EpicsCAOptions" }, { "$ref": "#/$defs/TangoOptions" diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index a150b61..9f1bdd8 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -8,10 +8,9 @@ from fastcs.datatypes import Bool, Enum, Float, Int, Table, Waveform from fastcs.launch import FastCS from fastcs.transport.epics.options import ( - EpicsBackend, EpicsIOCOptions, - EpicsOptions, ) +from fastcs.transport.epics.pva.options import EpicsPVAOptions from fastcs.wrappers import command, scan @@ -66,9 +65,7 @@ async def i(self): def run(pv_prefix="P4P_TEST_DEVICE"): - p4p_options = EpicsOptions( - ioc=EpicsIOCOptions(pv_prefix=pv_prefix), backend=EpicsBackend.P4P - ) + p4p_options = EpicsPVAOptions(ioc=EpicsIOCOptions(pv_prefix=pv_prefix)) controller = ParentController() controller.register_sub_controller( "Child1", ChildController(description="some sub controller") diff --git a/tests/example_softioc.py b/tests/example_softioc.py index 1ff17d2..69cd3ad 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -2,7 +2,8 @@ from fastcs.controller import Controller, SubController from fastcs.datatypes import Int from fastcs.launch import FastCS -from fastcs.transport.epics.options import EpicsBackend, EpicsIOCOptions, EpicsOptions +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions from fastcs.wrappers import command @@ -20,9 +21,7 @@ async def d(self): def run(pv_prefix="SOFTIOC_TEST_DEVICE"): - epics_options = EpicsOptions( - ioc=EpicsIOCOptions(pv_prefix=pv_prefix), backend=EpicsBackend.SOFT_IOC - ) + epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix=pv_prefix)) controller = ParentController() controller.register_sub_controller("Child", ChildController()) fastcs = FastCS(controller, [epics_options]) diff --git a/tests/transport/epics/softioc/test_gui.py b/tests/transport/epics/ca/test_gui.py similarity index 100% rename from tests/transport/epics/softioc/test_gui.py rename to tests/transport/epics/ca/test_gui.py diff --git a/tests/transport/epics/softioc/test_softioc.py b/tests/transport/epics/ca/test_softioc.py similarity index 92% rename from tests/transport/epics/softioc/test_softioc.py rename to tests/transport/epics/ca/test_softioc.py index f4d9a8b..fdaf877 100644 --- a/tests/transport/epics/softioc/test_softioc.py +++ b/tests/transport/epics/ca/test_softioc.py @@ -17,7 +17,7 @@ from fastcs.cs_methods import Command from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform from fastcs.exceptions import FastCSException -from fastcs.transport.epics.softioc.ioc import ( +from fastcs.transport.epics.ca.ioc import ( EPICS_MAX_NAME_LENGTH, EpicsIOC, _add_attr_pvi_info, @@ -27,7 +27,7 @@ _create_and_link_write_pv, _make_record, ) -from fastcs.transport.epics.softioc.util import ( +from fastcs.transport.epics.ca.util import ( MBB_STATE_FIELDS, record_metadata_from_attribute, record_metadata_from_datatype, @@ -51,10 +51,8 @@ def record_input_from_enum(enum_cls: type[enum.IntEnum]) -> dict[str, str]: @pytest.mark.asyncio async def test_create_and_link_read_pv(mocker: MockerFixture): - make_record = mocker.patch("fastcs.transport.epics.softioc.ioc._make_record") - add_attr_pvi_info = mocker.patch( - "fastcs.transport.epics.softioc.ioc._add_attr_pvi_info" - ) + make_record = mocker.patch("fastcs.transport.epics.ca.ioc._make_record") + add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ca.ioc._add_attr_pvi_info") record = make_record.return_value attribute = AttrR(Int()) @@ -96,7 +94,7 @@ def test_make_input_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.transport.epics.softioc.util.builder") + builder = mocker.patch("fastcs.transport.epics.ca.util.builder") pv = "PV" _make_record(pv, attribute) @@ -117,10 +115,8 @@ 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.transport.epics.softioc.ioc._make_record") - add_attr_pvi_info = mocker.patch( - "fastcs.transport.epics.softioc.ioc._add_attr_pvi_info" - ) + make_record = mocker.patch("fastcs.transport.epics.ca.ioc._make_record") + add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ca.ioc._add_attr_pvi_info") record = make_record.return_value attribute = AttrW(Int()) @@ -162,7 +158,7 @@ def test_make_output_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.transport.epics.softioc.util.builder") + builder = mocker.patch("fastcs.transport.epics.ca.util.builder") update = mocker.MagicMock() pv = "PV" @@ -201,11 +197,11 @@ def controller(class_mocker: MockerFixture): def test_ioc(mocker: MockerFixture, controller: Controller): - ioc_builder = mocker.patch("fastcs.transport.epics.softioc.ioc.builder") - builder = mocker.patch("fastcs.transport.epics.softioc.util.builder") - add_pvi_info = mocker.patch("fastcs.transport.epics.softioc.ioc._add_pvi_info") + ioc_builder = mocker.patch("fastcs.transport.epics.ca.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ca.util.builder") + add_pvi_info = mocker.patch("fastcs.transport.epics.ca.ioc._add_pvi_info") add_sub_controller_pvi_info = mocker.patch( - "fastcs.transport.epics.softioc.ioc._add_sub_controller_pvi_info" + "fastcs.transport.epics.ca.ioc._add_sub_controller_pvi_info" ) EpicsIOC(DEVICE, controller) @@ -280,7 +276,7 @@ def test_ioc(mocker: MockerFixture, controller: Controller): def test_add_pvi_info(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.softioc.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ca.ioc.builder") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -308,7 +304,7 @@ def test_add_pvi_info(mocker: MockerFixture): def test_add_pvi_info_with_parent(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.softioc.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ca.ioc.builder") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -344,7 +340,7 @@ def test_add_pvi_info_with_parent(mocker: MockerFixture): def test_add_sub_controller_pvi_info(mocker: MockerFixture): - add_pvi_info = mocker.patch("fastcs.transport.epics.softioc.ioc._add_pvi_info") + add_pvi_info = mocker.patch("fastcs.transport.epics.ca.ioc._add_pvi_info") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -394,8 +390,8 @@ class ControllerLongNames(Controller): def test_long_pv_names_discarded(mocker: MockerFixture): - ioc_builder = mocker.patch("fastcs.transport.epics.softioc.ioc.builder") - builder = mocker.patch("fastcs.transport.epics.softioc.util.builder") + ioc_builder = mocker.patch("fastcs.transport.epics.ca.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ca.util.builder") long_name_controller = ControllerLongNames() 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" @@ -469,7 +465,7 @@ def test_long_pv_names_discarded(mocker: MockerFixture): def test_update_datatype(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.softioc.util.builder") + builder = mocker.patch("fastcs.transport.epics.ca.util.builder") pv_name = f"{DEVICE}:Attr" diff --git a/tests/transport/epics/softioc/test_softioc_system.py b/tests/transport/epics/ca/test_softioc_system.py similarity index 100% rename from tests/transport/epics/softioc/test_softioc_system.py rename to tests/transport/epics/ca/test_softioc_system.py diff --git a/tests/transport/epics/softioc/test_util.py b/tests/transport/epics/ca/test_util.py similarity index 100% rename from tests/transport/epics/softioc/test_util.py rename to tests/transport/epics/ca/test_util.py diff --git a/tests/transport/epics/p4p/test_p4p.py b/tests/transport/epics/pva/test_p4p.py similarity index 87% rename from tests/transport/epics/p4p/test_p4p.py rename to tests/transport/epics/pva/test_p4p.py index 31e12b4..10022ad 100644 --- a/tests/transport/epics/p4p/test_p4p.py +++ b/tests/transport/epics/pva/test_p4p.py @@ -16,7 +16,8 @@ from fastcs.controller import Controller, SubController from fastcs.datatypes import Bool, Enum, Float, Int, String, Table, Waveform from fastcs.launch import FastCS -from fastcs.transport.epics.options import EpicsBackend, EpicsIOCOptions, EpicsOptions +from fastcs.transport.epics.options import EpicsIOCOptions +from fastcs.transport.epics.pva.options import EpicsPVAOptions @pytest.mark.asyncio @@ -181,13 +182,13 @@ async def test_numerical_alarms(p4p_subprocess: tuple[str, Queue]): assert value["value"] == 0 assert isinstance(value["value"], int) assert value["alarm"]["severity"] == 0 - assert value["alarm"]["message"] == "No alarm." + assert value["alarm"]["message"] == "No alarm" value = (await b_values.get()).raw assert value["value"] == 0 assert isinstance(value["value"], float) assert value["alarm"]["severity"] == 0 - assert value["alarm"]["message"] == "No alarm." + assert value["alarm"]["message"] == "No alarm" await ctxt.put(f"{pv_prefix}:A", 40_001) await ctxt.put(f"{pv_prefix}:B", -0.6) @@ -196,13 +197,13 @@ async def test_numerical_alarms(p4p_subprocess: tuple[str, Queue]): assert value["value"] == 40_001 assert isinstance(value["value"], int) assert value["alarm"]["severity"] == 2 - assert value["alarm"]["message"] == "Above maximum." + assert value["alarm"]["message"] == "Above maximum alarm limit: 40000" value = (await b_values.get()).raw assert value["value"] == -0.6 assert isinstance(value["value"], float) assert value["alarm"]["severity"] == 2 - assert value["alarm"]["message"] == "Below minimum." + assert value["alarm"]["message"] == "Below minimum alarm limit: -0.5" await ctxt.put(f"{pv_prefix}:A", 40_000) await ctxt.put(f"{pv_prefix}:B", -0.5) @@ -211,13 +212,13 @@ async def test_numerical_alarms(p4p_subprocess: tuple[str, Queue]): assert value["value"] == 40_000 assert isinstance(value["value"], int) assert value["alarm"]["severity"] == 0 - assert value["alarm"]["message"] == "No alarm." + assert value["alarm"]["message"] == "No alarm" value = (await b_values.get()).raw assert value["value"] == -0.5 assert isinstance(value["value"], float) assert value["alarm"]["severity"] == 0 - assert value["alarm"]["message"] == "No alarm." + assert value["alarm"]["message"] == "No alarm" assert a_values.empty() assert b_values.empty() @@ -228,9 +229,7 @@ async def test_numerical_alarms(p4p_subprocess: tuple[str, Queue]): def make_fastcs(pv_prefix: str, controller: Controller) -> FastCS: - epics_options = EpicsOptions( - ioc=EpicsIOCOptions(pv_prefix=pv_prefix), backend=EpicsBackend.P4P - ) + epics_options = EpicsPVAOptions(ioc=EpicsIOCOptions(pv_prefix=pv_prefix)) return FastCS(controller, [epics_options]) @@ -276,17 +275,21 @@ async def _wait_and_set_attr_r(): def test_pvi_grouping(): class ChildChildController(SubController): - attr_d: AttrR = AttrR(String()) + attr_e: AttrRW = AttrRW(Int()) + attr_f: AttrR = AttrR(String()) class ChildController(SubController): attr_c: AttrW = AttrW(Bool(), description="Some bool") + attr_d: AttrW = AttrW(String()) class SomeController(Controller): + description = "some controller" attr_1: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) attr_1: AttrRW = AttrRW(Float(min=-1, min_alarm=-0.5, prec=2)) another_attr_0: AttrRW = AttrRW(Int()) another_attr_1000: AttrRW = AttrRW(Int()) a_third_attr: AttrW = AttrW(Int()) + child_attribute_same_name: AttrR = AttrR(Int()) controller = SomeController() @@ -305,6 +308,8 @@ class SomeController(Controller): sub_controller = ChildController() controller.register_sub_controller("AdditionalChild", sub_controller) sub_controller.register_sub_controller("ChildChild", ChildChildController()) + sub_controller = ChildController() + controller.register_sub_controller("child_attribute_same_name", sub_controller) pv_prefix = str(uuid4()) fastcs = make_fastcs(pv_prefix, controller) @@ -336,7 +341,7 @@ class SomeController(Controller): assert len(controller_pvi) == 1 assert controller_pvi[0].todict() == { "alarm": {"message": "", "severity": 0, "status": 0}, - "display": {"description": ""}, + "display": {"description": "some controller"}, "timeStamp": { "nanoseconds": ANY, "secondsPastEpoch": ANY, @@ -344,7 +349,7 @@ class SomeController(Controller): }, "value": { "additional_child": {"d": f"{pv_prefix}:AdditionalChild:PVI"}, - "another_child": {"d": f"{pv_prefix}:another_child:PVI"}, + "another_child": {"d": f"{pv_prefix}:AnotherChild:PVI"}, "another_attr": { "rw": { "v0": f"{pv_prefix}:AnotherAttr0", @@ -352,7 +357,7 @@ class SomeController(Controller): } }, "a_third_attr": {"w": f"{pv_prefix}:AThirdAttr"}, - "attr": {"rw": f"{pv_prefix}:Attr1"}, + "attr": {"rw": {"v1": f"{pv_prefix}:Attr1"}}, "child": { "d": { "v0": f"{pv_prefix}:Child0:PVI", @@ -360,6 +365,10 @@ class SomeController(Controller): "v2": f"{pv_prefix}:Child2:PVI", } }, + "child_attribute_same_name": { + "d": f"{pv_prefix}:ChildAttributeSameName:PVI", + "r": f"{pv_prefix}:ChildAttributeSameName", + }, }, } assert len(child_controller_pvi) == 1 @@ -373,6 +382,9 @@ class SomeController(Controller): }, "value": { "attr_c": {"w": f"{pv_prefix}:Child0:AttrC"}, + "attr_d": { + "w": f"{pv_prefix}:Child0:AttrD", + }, "child_child": {"d": f"{pv_prefix}:Child0:ChildChild:PVI"}, }, } @@ -385,7 +397,10 @@ class SomeController(Controller): "secondsPastEpoch": ANY, "userTag": 0, }, - "value": {"attr_d": {"r": f"{pv_prefix}:Child0:ChildChild:AttrD"}}, + "value": { + "attr_e": {"rw": f"{pv_prefix}:Child0:ChildChild:AttrE"}, + "attr_f": {"r": f"{pv_prefix}:Child0:ChildChild:AttrF"}, + }, } @@ -395,6 +410,7 @@ def test_more_exotic_dataypes(): ("B", "i"), ("C", "?"), ("D", "f"), + ("E", "h"), ] class AnEnum(enum.Enum): @@ -411,7 +427,7 @@ class SomeController(Controller): pv_prefix = str(uuid4()) fastcs = make_fastcs(pv_prefix, controller) - ctxt = ThreadContext("pva") + ctxt = ThreadContext("pva", nt=False) initial_waveform_value = np.zeros((10, 10), dtype=np.int64) initial_table_value = np.array([], dtype=table_columns) @@ -419,24 +435,26 @@ class SomeController(Controller): server_set_waveform_value = np.copy(initial_waveform_value) server_set_waveform_value[0] = np.arange(10) - server_set_table_value = np.array([(1, 2, False, 3.14)], dtype=table_columns) + server_set_table_value = np.array([(1, 2, False, 3.14, 1)], dtype=table_columns) server_set_enum_value = AnEnum.B client_put_waveform_value = np.copy(server_set_waveform_value) client_put_waveform_value[1] = np.arange(10) client_put_table_value = NTTable(columns=table_columns).wrap( [ - {"A": 1, "B": 2, "C": False, "D": 3.14}, - {"A": 5, "B": 2, "C": True, "D": 6.28}, + {"A": 1, "B": 2, "C": False, "D": 3.14, "E": 1}, + {"A": 5, "B": 2, "C": True, "D": 6.28, "E": 2}, ] ) client_put_enum_value = "C" async def _wait_and_set_attrs(): await asyncio.sleep(0.1) - await controller.some_waveform.set(server_set_waveform_value) - await controller.some_table.set(server_set_table_value) - await controller.some_enum.set(server_set_enum_value) + await asyncio.gather( + controller.some_waveform.set(server_set_waveform_value), + controller.some_table.set(server_set_table_value), + controller.some_enum.set(server_set_enum_value), + ) async def _wait_and_put_pvs(): await asyncio.sleep(0.3) @@ -460,7 +478,8 @@ async def _wait_and_put_pvs(): try: asyncio.get_event_loop().run_until_complete( asyncio.wait_for( - asyncio.gather(serve, wait_and_set_attrs, wait_and_put_pvs), timeout=0.6 + asyncio.gather(serve, wait_and_set_attrs, wait_and_put_pvs), + timeout=0.6, ) ) except TimeoutError: @@ -483,7 +502,7 @@ async def _wait_and_put_pvs(): expected_waveform_gets, waveform_values, strict=True ): np.testing.assert_array_equal( - expected_waveform, actual_waveform.raw.value.reshape(10, 10) + expected_waveform, actual_waveform.todict()["value"].reshape(10, 10) ) expected_table_gets = [ @@ -513,4 +532,9 @@ async def _wait_and_put_pvs(): for expected_enum, actual_enum in zip( expected_enum_gets, enum_values, strict=True ): - assert expected_enum == controller.some_enum.datatype.members[actual_enum] # type: ignore + assert ( + expected_enum + == controller.some_enum.datatype.members[ # type: ignore + actual_enum.todict()["value"]["index"] + ] + )