From 116c1c280031dddb52090771e1b9bef44d3c4090 Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Mon, 3 Feb 2025 11:36:58 +0000 Subject: [PATCH 1/8] P4P Backend Still needs tests. --- pyproject.toml | 1 + src/fastcs/controller.py | 14 +- src/fastcs/datatypes.py | 14 +- src/fastcs/launch.py | 25 +- src/fastcs/transport/p4p/__init__.py | 0 src/fastcs/transport/p4p/adapter.py | 30 +++ src/fastcs/transport/p4p/ioc.py | 68 +++++ src/fastcs/transport/p4p/options.py | 11 + src/fastcs/transport/p4p/types.py | 355 +++++++++++++++++++++++++++ tests/data/schema.json | 23 ++ tests/p4p_ioc.py | 62 +++++ 11 files changed, 589 insertions(+), 14 deletions(-) create mode 100644 src/fastcs/transport/p4p/__init__.py create mode 100644 src/fastcs/transport/p4p/adapter.py create mode 100644 src/fastcs/transport/p4p/ioc.py create mode 100644 src/fastcs/transport/p4p/options.py create mode 100644 src/fastcs/transport/p4p/types.py create mode 100644 tests/p4p_ioc.py diff --git a/pyproject.toml b/pyproject.toml index d84c768b1..4058d83b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pytango", "softioc>=4.5.0", "strawberry-graphql", + "p4p" ] dynamic = ["version"] license.file = "LICENSE" diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 533b0d0d1..32786c274 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -23,7 +23,11 @@ class BaseController: #: Attributes passed from the device at runtime. attributes: dict[str, Attribute] - def __init__(self, path: list[str] | None = None) -> None: + def __init__( + self, path: list[str] | None = None, description: str | None = None + ) -> None: + self.description = description + if not hasattr(self, "attributes"): self.attributes = {} self._path: list[str] = path or [] @@ -130,8 +134,8 @@ class Controller(BaseController): generating a UI or creating parameters for a control system. """ - def __init__(self) -> None: - super().__init__() + def __init__(self, description: str | None = None) -> None: + super().__init__(description=description) async def initialise(self) -> None: pass @@ -149,5 +153,5 @@ class SubController(BaseController): root_attribute: Attribute | None = None - def __init__(self) -> None: - super().__init__() + def __init__(self, description: str | None = None) -> None: + super().__init__(description=description) diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py index ef1339ae6..2cf258a14 100644 --- a/src/fastcs/datatypes.py +++ b/src/fastcs/datatypes.py @@ -46,10 +46,10 @@ def initial_value(self) -> T: @dataclass(frozen=True) class _Numerical(DataType[T_Numerical]): units: str | None = None - min: int | None = None - max: int | None = None - min_alarm: int | None = None - max_alarm: int | None = None + min: float | None = None + max: float | None = None + min_alarm: float | None = None + max_alarm: float | None = None def validate(self, value: T_Numerical) -> T_Numerical: super().validate(value) @@ -83,6 +83,12 @@ class Float(_Numerical[float]): def dtype(self) -> type[float]: return float + def validate(self, value: float) -> float: + super().validate(value) + if self.prec is not None: + value = round(value, self.prec) + return value + @dataclass(frozen=True) class Bool(DataType[bool]): diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index ba0eebd11..38bd39968 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -1,6 +1,7 @@ import asyncio import inspect import json +import signal from pathlib import Path from typing import Annotated, Any, Optional, TypeAlias, get_type_hints @@ -16,12 +17,13 @@ from .transport.adapter import TransportAdapter from .transport.epics.options import EpicsOptions from .transport.graphQL.options import GraphQLOptions +from .transport.p4p.options import P4POptions 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 + EpicsOptions | TangoOptions | RestOptions | GraphQLOptions | P4POptions ] @@ -32,6 +34,7 @@ def __init__( transport_options: TransportOptions, ): self._loop = asyncio.get_event_loop() + self._loop.set_debug(True) self._backend = Backend(controller, self._loop) transport: TransportAdapter self._transports: list[TransportAdapter] = [] @@ -67,6 +70,13 @@ def __init__( controller, option, ) + case P4POptions(): + from .transport.p4p.adapter import P4PTransport + + transport = P4PTransport( + controller, + option, + ) self._transports.append(transport) def create_docs(self) -> None: @@ -80,14 +90,19 @@ def create_gui(self) -> None: transport.create_gui() def run(self): - self._loop.run_until_complete( - self.serve(), - ) + serve = asyncio.ensure_future(self.serve()) + + self._loop.add_signal_handler(signal.SIGINT, serve.cancel) + self._loop.add_signal_handler(signal.SIGTERM, serve.cancel) + self._loop.run_until_complete(serve) async def serve(self) -> None: coros = [self._backend.serve()] coros.extend([transport.serve() for transport in self._transports]) - await asyncio.gather(*coros) + try: + await asyncio.gather(*coros) + except asyncio.CancelledError: + pass def launch( diff --git a/src/fastcs/transport/p4p/__init__.py b/src/fastcs/transport/p4p/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastcs/transport/p4p/adapter.py b/src/fastcs/transport/p4p/adapter.py new file mode 100644 index 000000000..eeddc71df --- /dev/null +++ b/src/fastcs/transport/p4p/adapter.py @@ -0,0 +1,30 @@ +from fastcs.controller import Controller +from fastcs.transport.adapter import TransportAdapter + +from .ioc import P4PIOC +from .options import P4POptions + + +class P4PTransport(TransportAdapter): + def __init__( + self, + controller: Controller, + options: P4POptions | None = None, + ) -> None: + self._controller = controller + self._options = options or P4POptions() + self._pv_prefix = self.options.ioc.pv_prefix + self._ioc = P4PIOC(self.options.ioc.pv_prefix, controller) + + @property + def options(self) -> P4POptions: + return self._options + + async def serve(self) -> None: + await self._ioc.run() + + def create_docs(self) -> None: + raise NotImplementedError + + def create_gui(self) -> None: + raise NotImplementedError diff --git a/src/fastcs/transport/p4p/ioc.py b/src/fastcs/transport/p4p/ioc.py new file mode 100644 index 000000000..bf90bb823 --- /dev/null +++ b/src/fastcs/transport/p4p/ioc.py @@ -0,0 +1,68 @@ +import asyncio +from types import MethodType + +from p4p.server import Server, StaticProvider + +from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.controller import Controller + +from .types import AccessModeType, PviTree, make_command_pv, make_shared_pv + +_attr_to_access: dict[type[Attribute], AccessModeType] = { + AttrR: "r", + AttrW: "w", + AttrRW: "rw", +} + + +def get_pv_name(pv_prefix: str, attribute_name: str) -> str: + return f"{pv_prefix}:{attribute_name.title().replace('_', '')}" + + +async def parse_attributes( + prefix_root: str, controller: Controller +) -> list[StaticProvider]: + providers = [] + pvi_tree = PviTree() + + for single_mapping in controller.get_controller_mappings(): + path = single_mapping.controller.path + pv_prefix = ":".join([prefix_root] + path) + provider = StaticProvider(pv_prefix) + providers.append(provider) + + 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)]) + + for attr_name, method in single_mapping.command_methods.items(): + pv_name = get_pv_name(pv_prefix, attr_name) + command_pv = make_command_pv( + MethodType(method.fn, single_mapping.controller) + ) + provider.add(pv_name, command_pv) + pvi_tree.add_field(pv_name, "command") + + pvi_tree.add_block(pv_prefix, description=single_mapping.controller.description) + + providers.append(pvi_tree.make_provider()) + return providers + + +class P4PIOC: + def __init__( + self, + pv_prefix: str, + controller: Controller, + ): + self.pv_prefix = pv_prefix + self.controller = controller + + async def run(self): + providers = await parse_attributes(self.pv_prefix, self.controller) + + endless_event = asyncio.Event() + with Server(providers): + await endless_event.wait() diff --git a/src/fastcs/transport/p4p/options.py b/src/fastcs/transport/p4p/options.py new file mode 100644 index 000000000..c14b48ae8 --- /dev/null +++ b/src/fastcs/transport/p4p/options.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass, field + + +@dataclass +class P4PIOCOptions: + pv_prefix: str = "MY-DEVICE-PREFIX" + + +@dataclass +class P4POptions: + ioc: P4PIOCOptions = field(default_factory=P4PIOCOptions) diff --git a/src/fastcs/transport/p4p/types.py b/src/fastcs/transport/p4p/types.py new file mode 100644 index 000000000..3e9cbea59 --- /dev/null +++ b/src/fastcs/transport/p4p/types.py @@ -0,0 +1,355 @@ +import asyncio +import time +from collections.abc import Callable +from dataclasses import asdict +from typing import Literal, TypedDict + +from p4p import Type, Value +from p4p.nt import NTEnum, NTNDArray, NTScalar +from p4p.nt.common import alarm, timeStamp +from p4p.server import ServerOperation, StaticProvider +from p4p.server.asyncio import SharedPV + +from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.datatypes import Bool, Enum, Float, Int, String, T, Waveform + +P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform) + + +_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 +_MAJOR_ALARM_SEVERITY = 2 +_NO_ALARM_SEVERITY = 0 + + +def _get_nt_scalar_from_attribute( + attribute: Attribute, +) -> NTScalar | NTEnum | NTNDArray: + match attribute.datatype: + case Int(): + return _P4P_INT + case Float(): + return _P4P_FLOAT + case String(): + return _P4P_STRING + case Bool(): + return _P4P_BOOL + # `NTEnum/NTNDArray.wrap` don't accept extra fields until + # https://github.com/epics-base/p4p/issues/166 + case Enum(): + return NTEnum() + case Waveform(): + # TODO: Make 1D scalar array for 1D shapes + # This will require converting from np.int32 to "ai" + # if len(shape) == 1: + # return NTScalarArray(convert np.datatype32 to string "ad") + # TODO: Add an option for allowing shape to change, if so we will + # use an NDArray here even if shape is 1D + + return NTNDArray() + case _: + raise RuntimeError(f"Datatype `{attribute.datatype}` unsupported in P4P.") + + +def _cast_from_p4p_type(attribute: Attribute[T], value: object) -> T: + match attribute.datatype: + case Enum(): + return attribute.datatype.validate(attribute.datatype.members[value.index]) + case attribute.datatype if issubclass( + type(attribute.datatype), P4P_ALLOWED_DATATYPES + ): + return attribute.datatype.validate(value) # type: ignore + case _: + raise ValueError(f"Unsupported datatype {attribute.datatype}") + + +def _p4p_alarm_states( + severity: int = _NO_ALARM_SEVERITY, + status: int = _NO_ALARM_STATUS, + message: str = "", +) -> dict: + return { + "alarm": { + "severity": severity, + "status": status, + "message": message, + }, + } + + +def _p4p_timestamp_now() -> dict: + now = time.time() + seconds_past_epoch = int(now) + nanoseconds = int((now - seconds_past_epoch) * 1e9) + return { + "timeStamp": { + "secondsPastEpoch": seconds_past_epoch, + "nanoseconds": nanoseconds, + } + } + + +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 + severity = ( + _MAJOR_ALARM_SEVERITY + if high is not None or low is not None + else _NO_ALARM_SEVERITY + ) + status, message = _NO_ALARM_SEVERITY, "No alarm." + if low: + status, message = _RECORD_ALARM_STATUS, "Below minimum." + if high: + status, message = _RECORD_ALARM_STATUS, "Above maximum." + return _p4p_alarm_states(severity, status, message) + + +def _cast_to_p4p_type(attribute: Attribute[T], value: T) -> object: + match attribute.datatype: + case Enum(): + return { + "index": attribute.datatype.index_of(value), + "choices": [member.name for member in attribute.datatype.members], + } + case Waveform(): + 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 + if isinstance(datatype, (Float | Int)): + record_fields.update( + _p4p_check_numerical_for_alarm_states( + datatype.min_alarm, + datatype.max_alarm, + 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_nt_scalar_from_attribute(attribute).wrap(record_fields) # type: ignore + case _: + raise ValueError(f"Unsupported datatype {attribute.datatype}") + + +class AttrWHandler: + def __init__(self, attr_w: AttrW | AttrRW): + self._attr_w = attr_w + + async def put(self, pv: SharedPV, op: ServerOperation): + value = op.value() + raw_value = value.raw.value + + cast_value = _cast_from_p4p_type(self._attr_w, raw_value) + await self._attr_w.process_without_display_update(cast_value) + + pv.post(_cast_to_p4p_type(self._attr_w, cast_value)) + op.done() + + +def make_shared_pv(attribute: Attribute) -> SharedPV: + initial_value = ( + attribute.get() + if isinstance(attribute, AttrRW | AttrR) + else attribute.datatype.initial_value + ) + kwargs = { + "nt": _get_nt_scalar_from_attribute(attribute), + "initial": _cast_to_p4p_type(attribute, initial_value), + } + + if isinstance(attribute, (AttrW | AttrRW)): + kwargs["handler"] = AttrWHandler(attribute) + + shared_pv = SharedPV(**kwargs) + + if isinstance(attribute, (AttrR | AttrRW)): + shared_pv.post(_cast_to_p4p_type(attribute, attribute.get())) + + async def on_update(value): + shared_pv.post(_cast_to_p4p_type(attribute, value)) + + attribute.set_update_callback(on_update) + + return shared_pv + + +class CommandHandler: + def __init__(self, command: Callable): + self._command = command + self._last_task: asyncio.Future | None = None + self._task_started_event = asyncio.Event() + + async def _run_command(self, pv: SharedPV): + self._task_started_event.set() + self._task_started_event.clear() + + kwargs = {} + try: + await self._command() + except Exception as e: + kwargs.update( + _p4p_alarm_states(_MAJOR_ALARM_SEVERITY, _RECORD_ALARM_STATUS, str(e)) + ) + else: + kwargs.update(_p4p_alarm_states()) + + value = NTScalar("?").wrap({"value": False, **kwargs}) + timestamp = time.time() + pv.close() + pv.open(value, timestamp=timestamp) + pv.post(value, timestamp=timestamp) + + async def put(self, pv: SharedPV, op: ServerOperation): + value = op.value() + raw_value = value.raw.value + + if ( + raw_value is False + and self._last_task is not None + and not self._last_task.done() + ): + self._last_task.cancel() + try: + await self._last_task + except asyncio.CancelledError: + pass + elif ( + raw_value is True + and self._last_task is not None + and not self._last_task.done() + ): + raise RuntimeError( + f"{self._command} is already running, received signal to run it again." + ) + + elif not isinstance(raw_value, bool): + raise ValueError( + "Command PVs are `True` while the command is running, `False` once " + "it's finished. `False` can be put to stop the running command." + ) + + if raw_value is True: + self._last_task = asyncio.create_task(self._run_command(pv)) + await self._task_started_event.wait() + + # Flip to true once command task starts + pv.post(value, timestamp=time.time()) + op.done() + + +def make_command_pv(command: Callable) -> SharedPV: + shared_pv = SharedPV( + nt=NTScalar("?"), + initial=False, + handler=CommandHandler(command), + ) + + return shared_pv + + +AccessModeType = Literal["r", "w", "rw", "pvi", "command"] + + +class _PviFieldInfo(TypedDict): + pv: str + access: AccessModeType + + +class _PviBlockDisplay(TypedDict): + description: str + + +class _PviBlockInfo(TypedDict): + display: _PviBlockDisplay + value: list[_PviFieldInfo] + + +class PviTree: + _P4PType = Type( + [ + ("alarm", alarm), + ("timeStamp", timeStamp), + ("display", ("S", None, [("description", "s")])), + ( + "value", + ("aS", None, [("pv", "s"), ("access", "s")]), + ), + ] + ) + + def __init__(self): + self._pvi_info: dict[str, _PviBlockInfo] = {} + + def add_block(self, block_pv: str, description: str | None = None): + if block_pv not in self._pvi_info: + self._pvi_info[block_pv] = _PviBlockInfo( + display=_PviBlockDisplay(description=(description or "")), value=[] + ) + elif ( + description is not None + and self._pvi_info[block_pv]["display"]["description"] != description + ): + # Allows field info to be added before the block info. + # Not needed in the case of `controller.get_mappings`, + # but still useful. + self._pvi_info[block_pv]["display"]["description"] = description + + parent_pv = block_pv.rsplit(":", maxsplit=1)[0] + if parent_pv != block_pv: + self._pvi_info[parent_pv]["value"].append( + _PviFieldInfo(pv=f"{block_pv}:PVI", access="pvi") + ) + + def add_field(self, attribute_pv: str, access: AccessModeType): + block_pv = attribute_pv.rsplit(":", maxsplit=1)[0] + if block_pv not in self._pvi_info: + self._pvi_info[block_pv] = _PviBlockInfo( + display=_PviBlockDisplay(description=""), value=[] + ) + self._pvi_info[block_pv]["value"].append( + _PviFieldInfo(pv=attribute_pv, access=access) + ) + + def make_provider(self) -> StaticProvider: + provider = StaticProvider("PVI") + for block_pv, block_pvi_info in self._pvi_info.items(): + provider.add( + f"{block_pv}:PVI", + SharedPV(initial=self._p4p_value(block_pvi_info)), + ) + return provider + + def _p4p_value(self, block_pvi_info: _PviBlockInfo) -> Value: + return Value( + self._P4PType, + {**_p4p_alarm_states(), **_p4p_timestamp_now(), **block_pvi_info}, + ) diff --git a/tests/data/schema.json b/tests/data/schema.json index 92fd7f3cf..e1791da22 100644 --- a/tests/data/schema.json +++ b/tests/data/schema.json @@ -109,6 +109,26 @@ "title": "GraphQLServerOptions", "type": "object" }, + "P4PIOCOptions": { + "properties": { + "pv_prefix": { + "default": "MY-DEVICE-PREFIX", + "title": "Pv Prefix", + "type": "string" + } + }, + "title": "P4PIOCOptions", + "type": "object" + }, + "P4POptions": { + "properties": { + "ioc": { + "$ref": "#/$defs/P4PIOCOptions" + } + }, + "title": "P4POptions", + "type": "object" + }, "RestOptions": { "properties": { "rest": { @@ -202,6 +222,9 @@ }, { "$ref": "#/$defs/GraphQLOptions" + }, + { + "$ref": "#/$defs/P4POptions" } ] }, diff --git a/tests/p4p_ioc.py b/tests/p4p_ioc.py new file mode 100644 index 000000000..6cab7b7f9 --- /dev/null +++ b/tests/p4p_ioc.py @@ -0,0 +1,62 @@ +import asyncio +import enum + +import numpy as np + +from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.controller import Controller, SubController +from fastcs.datatypes import Bool, Enum, Float, Int, Waveform +from fastcs.launch import FastCS +from fastcs.transport.p4p.options import P4PIOCOptions, P4POptions +from fastcs.wrappers import command, scan + + +class FEnum(enum.Enum): + A = 0 + B = 1 + C = "VALUES ARE ARBITRARY" + D = 2 + E = 5 + + +class ParentController(Controller): + a: AttrR = AttrR(Int()) + b: AttrRW = AttrRW(Float(min=-1, min_alarm=-0.5)) + + +class ChildController(SubController): + c: AttrW = AttrW(Int()) + + @command() + async def d(self): + print("D: RUNNING") + await asyncio.sleep(1) + print("D: FINISHED") + + e: AttrR = AttrR(Bool()) + + @scan(1) + async def flip_flop(self): + await self.e.set(not self.e.get()) + + f: AttrRW = AttrRW(Enum(FEnum)) + g: AttrRW = AttrRW(Waveform(np.int64, shape=(3,))) + h: AttrRW = AttrRW(Waveform(np.float64, shape=(3, 3))) + + @command() + async def i(self): + print("I: RUNNING") + await asyncio.sleep(1) + raise RuntimeError("I: FAILED WITH THIS WEIRD ERROR") + + +def run(): + p4p_options = P4POptions(ioc=P4PIOCOptions(pv_prefix="DEVICE")) + controller = ParentController() + controller.register_sub_controller("Child", ChildController()) + fastcs = FastCS(controller, [p4p_options]) + fastcs.run() + + +if __name__ == "__main__": + run() From 2e51858f8b2b396f51f10b19b96b82f4879c4317 Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Thu, 6 Feb 2025 11:23:37 +0000 Subject: [PATCH 2/8] seperated `epics` into `epics/softioc`, `epics/p4p` --- src/fastcs/launch.py | 36 +++++++++-------- src/fastcs/transport/__init__.py | 1 + src/fastcs/transport/epics/options.py | 6 +++ .../transport/{ => epics}/p4p/__init__.py | 0 .../transport/{ => epics}/p4p/adapter.py | 14 ++++--- src/fastcs/transport/{ => epics}/p4p/ioc.py | 0 src/fastcs/transport/{ => epics}/p4p/types.py | 0 .../transport/epics/softioc/__init__.py | 0 .../transport/epics/{ => softioc}/adapter.py | 9 ++--- .../transport/epics/{ => softioc}/ioc.py | 5 +-- .../transport/epics/{ => softioc}/util.py | 0 src/fastcs/transport/p4p/options.py | 11 ----- tests/benchmarking/controller.py | 7 +++- tests/data/schema.json | 35 ++++++---------- tests/ioc.py | 6 ++- tests/p4p_ioc.py | 10 ++++- .../transport/epics/{ => softioc}/test_gui.py | 0 .../transport/epics/{ => softioc}/test_ioc.py | 40 ++++++++++--------- .../epics/{ => softioc}/test_ioc_system.py | 0 tests/transport/epics/softioc/test_util.py | 0 20 files changed, 91 insertions(+), 89 deletions(-) rename src/fastcs/transport/{ => epics}/p4p/__init__.py (100%) rename src/fastcs/transport/{ => epics}/p4p/adapter.py (57%) rename src/fastcs/transport/{ => epics}/p4p/ioc.py (100%) rename src/fastcs/transport/{ => epics}/p4p/types.py (100%) rename tests/transport/epics/test_util.py => src/fastcs/transport/epics/softioc/__init__.py (100%) rename src/fastcs/transport/epics/{ => softioc}/adapter.py (83%) rename src/fastcs/transport/epics/{ => softioc}/ioc.py (98%) rename src/fastcs/transport/epics/{ => softioc}/util.py (100%) delete mode 100644 src/fastcs/transport/p4p/options.py rename tests/transport/epics/{ => softioc}/test_gui.py (100%) rename tests/transport/epics/{ => softioc}/test_ioc.py (92%) rename tests/transport/epics/{ => softioc}/test_ioc_system.py (100%) create mode 100644 tests/transport/epics/softioc/test_util.py diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index 38bd39968..a3babd5f5 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -15,15 +15,14 @@ from .controller import Controller from .exceptions import LaunchError from .transport.adapter import TransportAdapter -from .transport.epics.options import EpicsOptions +from .transport.epics.options import EpicsBackend, EpicsOptions from .transport.graphQL.options import GraphQLOptions -from .transport.p4p.options import P4POptions 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 | P4POptions + EpicsOptions | TangoOptions | RestOptions | GraphQLOptions ] @@ -40,14 +39,23 @@ def __init__( self._transports: list[TransportAdapter] = [] for option in transport_options: match option: - case EpicsOptions(): - from .transport.epics.adapter import EpicsTransport - - transport = EpicsTransport( - controller, - self._loop, - 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 TangoOptions(): from .transport.tango.adapter import TangoTransport @@ -70,13 +78,7 @@ def __init__( controller, option, ) - case P4POptions(): - from .transport.p4p.adapter import P4PTransport - transport = P4PTransport( - controller, - option, - ) self._transports.append(transport) def create_docs(self) -> None: diff --git a/src/fastcs/transport/__init__.py b/src/fastcs/transport/__init__.py index 36f3470e8..5efce3f41 100644 --- a/src/fastcs/transport/__init__.py +++ b/src/fastcs/transport/__init__.py @@ -1,3 +1,4 @@ +from .epics.options import EpicsBackend as EpicsBackend from .epics.options import EpicsDocsOptions as EpicsDocsOptions from .epics.options import EpicsGUIOptions as EpicsGUIOptions from .epics.options import EpicsIOCOptions as EpicsIOCOptions diff --git a/src/fastcs/transport/epics/options.py b/src/fastcs/transport/epics/options.py index f7131abfe..dc5ed4878 100644 --- a/src/fastcs/transport/epics/options.py +++ b/src/fastcs/transport/epics/options.py @@ -26,8 +26,14 @@ 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/p4p/__init__.py b/src/fastcs/transport/epics/p4p/__init__.py similarity index 100% rename from src/fastcs/transport/p4p/__init__.py rename to src/fastcs/transport/epics/p4p/__init__.py diff --git a/src/fastcs/transport/p4p/adapter.py b/src/fastcs/transport/epics/p4p/adapter.py similarity index 57% rename from src/fastcs/transport/p4p/adapter.py rename to src/fastcs/transport/epics/p4p/adapter.py index eeddc71df..cb3054d92 100644 --- a/src/fastcs/transport/p4p/adapter.py +++ b/src/fastcs/transport/epics/p4p/adapter.py @@ -1,30 +1,32 @@ from fastcs.controller import Controller 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 .ioc import P4PIOC -from .options import P4POptions class P4PTransport(TransportAdapter): def __init__( self, controller: Controller, - options: P4POptions | None = None, + options: EpicsOptions | None = None, ) -> None: self._controller = controller - self._options = options or P4POptions() + self._options = options or EpicsOptions() self._pv_prefix = self.options.ioc.pv_prefix self._ioc = P4PIOC(self.options.ioc.pv_prefix, controller) @property - def options(self) -> P4POptions: + def options(self) -> EpicsOptions: return self._options async def serve(self) -> None: await self._ioc.run() def create_docs(self) -> None: - raise NotImplementedError + EpicsDocs(self._controller).create_docs(self.options.docs) def create_gui(self) -> None: - raise NotImplementedError + EpicsGUI(self._controller, self._pv_prefix).create_gui(self.options.gui) diff --git a/src/fastcs/transport/p4p/ioc.py b/src/fastcs/transport/epics/p4p/ioc.py similarity index 100% rename from src/fastcs/transport/p4p/ioc.py rename to src/fastcs/transport/epics/p4p/ioc.py diff --git a/src/fastcs/transport/p4p/types.py b/src/fastcs/transport/epics/p4p/types.py similarity index 100% rename from src/fastcs/transport/p4p/types.py rename to src/fastcs/transport/epics/p4p/types.py diff --git a/tests/transport/epics/test_util.py b/src/fastcs/transport/epics/softioc/__init__.py similarity index 100% rename from tests/transport/epics/test_util.py rename to src/fastcs/transport/epics/softioc/__init__.py diff --git a/src/fastcs/transport/epics/adapter.py b/src/fastcs/transport/epics/softioc/adapter.py similarity index 83% rename from src/fastcs/transport/epics/adapter.py rename to src/fastcs/transport/epics/softioc/adapter.py index 4383fb579..34d91d461 100644 --- a/src/fastcs/transport/epics/adapter.py +++ b/src/fastcs/transport/epics/softioc/adapter.py @@ -2,11 +2,10 @@ from fastcs.controller import Controller from fastcs.transport.adapter import TransportAdapter - -from .docs import EpicsDocs -from .gui import EpicsGUI -from .ioc import EpicsIOC -from .options import EpicsOptions +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): diff --git a/src/fastcs/transport/epics/ioc.py b/src/fastcs/transport/epics/softioc/ioc.py similarity index 98% rename from src/fastcs/transport/epics/ioc.py rename to src/fastcs/transport/epics/softioc/ioc.py index 576586691..bd276ced1 100644 --- a/src/fastcs/transport/epics/ioc.py +++ b/src/fastcs/transport/epics/softioc/ioc.py @@ -10,7 +10,8 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controller import BaseController, Controller from fastcs.datatypes import DataType, T -from fastcs.transport.epics.util import ( +from fastcs.transport.epics.options import EpicsIOCOptions +from fastcs.transport.epics.softioc.util import ( builder_callable_from_attribute, cast_from_epics_type, cast_to_epics_type, @@ -18,8 +19,6 @@ record_metadata_from_datatype, ) -from .options import EpicsIOCOptions - EPICS_MAX_NAME_LENGTH = 60 diff --git a/src/fastcs/transport/epics/util.py b/src/fastcs/transport/epics/softioc/util.py similarity index 100% rename from src/fastcs/transport/epics/util.py rename to src/fastcs/transport/epics/softioc/util.py diff --git a/src/fastcs/transport/p4p/options.py b/src/fastcs/transport/p4p/options.py deleted file mode 100644 index c14b48ae8..000000000 --- a/src/fastcs/transport/p4p/options.py +++ /dev/null @@ -1,11 +0,0 @@ -from dataclasses import dataclass, field - - -@dataclass -class P4PIOCOptions: - pv_prefix: str = "MY-DEVICE-PREFIX" - - -@dataclass -class P4POptions: - ioc: P4PIOCOptions = field(default_factory=P4PIOCOptions) diff --git a/tests/benchmarking/controller.py b/tests/benchmarking/controller.py index 107b3f55f..4b874b5c9 100644 --- a/tests/benchmarking/controller.py +++ b/tests/benchmarking/controller.py @@ -2,7 +2,7 @@ from fastcs.attributes import AttrR, AttrW from fastcs.controller import Controller from fastcs.datatypes import Bool, Int -from fastcs.transport.epics.options import EpicsIOCOptions, EpicsOptions +from fastcs.transport.epics.options import EpicsBackend, EpicsIOCOptions, EpicsOptions from fastcs.transport.rest.options import RestOptions, RestServerOptions from fastcs.transport.tango.options import TangoDSROptions, TangoOptions @@ -15,7 +15,10 @@ class TestController(Controller): def run(): transport_options = [ RestOptions(rest=RestServerOptions(port=8090)), - EpicsOptions(ioc=EpicsIOCOptions(pv_prefix="BENCHMARK-DEVICE")), + EpicsOptions( + ioc=EpicsIOCOptions(pv_prefix="BENCHMARK-DEVICE"), + backend=EpicsBackend.SOFT_IOC, + ), TangoOptions(dsr=TangoDSROptions(dev_name="MY/BENCHMARK/DEVICE")), ] instance = FastCS( diff --git a/tests/data/schema.json b/tests/data/schema.json index e1791da22..4d5398ff4 100644 --- a/tests/data/schema.json +++ b/tests/data/schema.json @@ -1,5 +1,13 @@ { "$defs": { + "EpicsBackend": { + "enum": [ + "softioc", + "p4p" + ], + "title": "EpicsBackend", + "type": "string" + }, "EpicsDocsOptions": { "properties": { "path": { @@ -74,6 +82,10 @@ }, "ioc": { "$ref": "#/$defs/EpicsIOCOptions" + }, + "backend": { + "$ref": "#/$defs/EpicsBackend", + "default": "softioc" } }, "title": "EpicsOptions", @@ -109,26 +121,6 @@ "title": "GraphQLServerOptions", "type": "object" }, - "P4PIOCOptions": { - "properties": { - "pv_prefix": { - "default": "MY-DEVICE-PREFIX", - "title": "Pv Prefix", - "type": "string" - } - }, - "title": "P4PIOCOptions", - "type": "object" - }, - "P4POptions": { - "properties": { - "ioc": { - "$ref": "#/$defs/P4PIOCOptions" - } - }, - "title": "P4POptions", - "type": "object" - }, "RestOptions": { "properties": { "rest": { @@ -222,9 +214,6 @@ }, { "$ref": "#/$defs/GraphQLOptions" - }, - { - "$ref": "#/$defs/P4POptions" } ] }, diff --git a/tests/ioc.py b/tests/ioc.py index a42ef6a29..157750a81 100644 --- a/tests/ioc.py +++ b/tests/ioc.py @@ -2,7 +2,7 @@ from fastcs.controller import Controller, SubController from fastcs.datatypes import Int from fastcs.launch import FastCS -from fastcs.transport.epics.options import EpicsIOCOptions, EpicsOptions +from fastcs.transport.epics.options import EpicsBackend, EpicsIOCOptions, EpicsOptions from fastcs.wrappers import command @@ -20,7 +20,9 @@ async def d(self): def run(): - epics_options = EpicsOptions(ioc=EpicsIOCOptions(pv_prefix="DEVICE")) + epics_options = EpicsOptions( + ioc=EpicsIOCOptions(pv_prefix="DEVICE"), backend=EpicsBackend.SOFT_IOC + ) controller = ParentController() controller.register_sub_controller("Child", ChildController()) fastcs = FastCS(controller, [epics_options]) diff --git a/tests/p4p_ioc.py b/tests/p4p_ioc.py index 6cab7b7f9..2074cde39 100644 --- a/tests/p4p_ioc.py +++ b/tests/p4p_ioc.py @@ -7,7 +7,11 @@ from fastcs.controller import Controller, SubController from fastcs.datatypes import Bool, Enum, Float, Int, Waveform from fastcs.launch import FastCS -from fastcs.transport.p4p.options import P4PIOCOptions, P4POptions +from fastcs.transport.epics.options import ( + EpicsBackend, + EpicsIOCOptions, + EpicsOptions, +) from fastcs.wrappers import command, scan @@ -51,7 +55,9 @@ async def i(self): def run(): - p4p_options = P4POptions(ioc=P4PIOCOptions(pv_prefix="DEVICE")) + p4p_options = EpicsOptions( + ioc=EpicsIOCOptions(pv_prefix="DEVICE"), backend=EpicsBackend.P4P + ) controller = ParentController() controller.register_sub_controller("Child", ChildController()) fastcs = FastCS(controller, [p4p_options]) diff --git a/tests/transport/epics/test_gui.py b/tests/transport/epics/softioc/test_gui.py similarity index 100% rename from tests/transport/epics/test_gui.py rename to tests/transport/epics/softioc/test_gui.py diff --git a/tests/transport/epics/test_ioc.py b/tests/transport/epics/softioc/test_ioc.py similarity index 92% rename from tests/transport/epics/test_ioc.py rename to tests/transport/epics/softioc/test_ioc.py index 98f72b412..f4d9a8bee 100644 --- a/tests/transport/epics/test_ioc.py +++ b/tests/transport/epics/softioc/test_ioc.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.ioc import ( +from fastcs.transport.epics.softioc.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.util import ( +from fastcs.transport.epics.softioc.util import ( MBB_STATE_FIELDS, record_metadata_from_attribute, record_metadata_from_datatype, @@ -51,8 +51,10 @@ 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.ioc._make_record") - add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_attr_pvi_info") + 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" + ) record = make_record.return_value attribute = AttrR(Int()) @@ -94,7 +96,7 @@ def test_make_input_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.transport.epics.util.builder") + builder = mocker.patch("fastcs.transport.epics.softioc.util.builder") pv = "PV" _make_record(pv, attribute) @@ -115,8 +117,10 @@ 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.ioc._make_record") - add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_attr_pvi_info") + 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" + ) record = make_record.return_value attribute = AttrW(Int()) @@ -158,7 +162,7 @@ def test_make_output_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.transport.epics.util.builder") + builder = mocker.patch("fastcs.transport.epics.softioc.util.builder") update = mocker.MagicMock() pv = "PV" @@ -197,11 +201,11 @@ def controller(class_mocker: MockerFixture): def test_ioc(mocker: MockerFixture, controller: Controller): - ioc_builder = mocker.patch("fastcs.transport.epics.ioc.builder") - builder = mocker.patch("fastcs.transport.epics.util.builder") - add_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_pvi_info") + 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") add_sub_controller_pvi_info = mocker.patch( - "fastcs.transport.epics.ioc._add_sub_controller_pvi_info" + "fastcs.transport.epics.softioc.ioc._add_sub_controller_pvi_info" ) EpicsIOC(DEVICE, controller) @@ -276,7 +280,7 @@ def test_ioc(mocker: MockerFixture, controller: Controller): def test_add_pvi_info(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.softioc.ioc.builder") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -304,7 +308,7 @@ def test_add_pvi_info(mocker: MockerFixture): def test_add_pvi_info_with_parent(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.softioc.ioc.builder") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -340,7 +344,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.ioc._add_pvi_info") + add_pvi_info = mocker.patch("fastcs.transport.epics.softioc.ioc._add_pvi_info") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -390,8 +394,8 @@ class ControllerLongNames(Controller): def test_long_pv_names_discarded(mocker: MockerFixture): - ioc_builder = mocker.patch("fastcs.transport.epics.ioc.builder") - builder = mocker.patch("fastcs.transport.epics.util.builder") + ioc_builder = mocker.patch("fastcs.transport.epics.softioc.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.softioc.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" @@ -465,7 +469,7 @@ def test_long_pv_names_discarded(mocker: MockerFixture): def test_update_datatype(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.util.builder") + builder = mocker.patch("fastcs.transport.epics.softioc.util.builder") pv_name = f"{DEVICE}:Attr" diff --git a/tests/transport/epics/test_ioc_system.py b/tests/transport/epics/softioc/test_ioc_system.py similarity index 100% rename from tests/transport/epics/test_ioc_system.py rename to tests/transport/epics/softioc/test_ioc_system.py diff --git a/tests/transport/epics/softioc/test_util.py b/tests/transport/epics/softioc/test_util.py new file mode 100644 index 000000000..e69de29bb From cf5d5af311b23d5149ca302e849c3c51be15fef7 Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Thu, 6 Feb 2025 15:05:22 +0000 Subject: [PATCH 3/8] matched pvi tree of those in production Still have to deal with multiple numbered subcontrollers more reliably. --- src/fastcs/controller.py | 5 +- src/fastcs/transport/epics/p4p/ioc.py | 15 +- src/fastcs/transport/epics/p4p/types.py | 225 ++++++++++++------ tests/conftest.py | 28 ++- tests/{p4p_ioc.py => example_p4p_ioc.py} | 10 +- tests/{ioc.py => example_softioc.py} | 0 tests/transport/epics/p4p/test_p4p_system.py | 36 +++ .../softioc/{test_ioc.py => test_softioc.py} | 0 ...t_ioc_system.py => test_softioc_system.py} | 2 +- 9 files changed, 237 insertions(+), 84 deletions(-) rename tests/{p4p_ioc.py => example_p4p_ioc.py} (80%) rename tests/{ioc.py => example_softioc.py} (100%) create mode 100644 tests/transport/epics/p4p/test_p4p_system.py rename tests/transport/epics/softioc/{test_ioc.py => test_softioc.py} (100%) rename tests/transport/epics/softioc/{test_ioc_system.py => test_softioc_system.py} (96%) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 32786c274..867abdfee 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -23,10 +23,13 @@ class BaseController: #: Attributes passed from the device at runtime. attributes: dict[str, Attribute] + description: str | None = None + def __init__( self, path: list[str] | None = None, description: str | None = None ) -> None: - self.description = description + if self.description is None: + self.description = description if not hasattr(self, "attributes"): self.attributes = {} diff --git a/src/fastcs/transport/epics/p4p/ioc.py b/src/fastcs/transport/epics/p4p/ioc.py index bf90bb823..334f39758 100644 --- a/src/fastcs/transport/epics/p4p/ioc.py +++ b/src/fastcs/transport/epics/p4p/ioc.py @@ -24,6 +24,11 @@ async def parse_attributes( ) -> list[StaticProvider]: providers = [] pvi_tree = PviTree() + pvi_tree.add_block( + prefix_root, + controller.description, + type(controller), + ) for single_mapping in controller.get_controller_mappings(): path = single_mapping.controller.path @@ -31,6 +36,12 @@ async def parse_attributes( 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) @@ -43,9 +54,7 @@ async def parse_attributes( MethodType(method.fn, single_mapping.controller) ) provider.add(pv_name, command_pv) - pvi_tree.add_field(pv_name, "command") - - pvi_tree.add_block(pv_prefix, description=single_mapping.controller.description) + pvi_tree.add_field(pv_name, "x") providers.append(pvi_tree.make_provider()) return providers diff --git a/src/fastcs/transport/epics/p4p/types.py b/src/fastcs/transport/epics/p4p/types.py index 3e9cbea59..307b24eaa 100644 --- a/src/fastcs/transport/epics/p4p/types.py +++ b/src/fastcs/transport/epics/p4p/types.py @@ -1,8 +1,9 @@ import asyncio +import re import time from collections.abc import Callable -from dataclasses import asdict -from typing import Literal, TypedDict +from dataclasses import asdict, dataclass +from typing import Literal from p4p import Type, Value from p4p.nt import NTEnum, NTNDArray, NTScalar @@ -11,6 +12,7 @@ from p4p.server.asyncio import SharedPV from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.controller import BaseController from fastcs.datatypes import Bool, Enum, Float, Int, String, T, Waveform P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform) @@ -205,7 +207,6 @@ async def on_update(value): class CommandHandler: def __init__(self, command: Callable): self._command = command - self._last_task: asyncio.Future | None = None self._task_started_event = asyncio.Event() async def _run_command(self, pv: SharedPV): @@ -232,33 +233,8 @@ async def put(self, pv: SharedPV, op: ServerOperation): value = op.value() raw_value = value.raw.value - if ( - raw_value is False - and self._last_task is not None - and not self._last_task.done() - ): - self._last_task.cancel() - try: - await self._last_task - except asyncio.CancelledError: - pass - elif ( - raw_value is True - and self._last_task is not None - and not self._last_task.done() - ): - raise RuntimeError( - f"{self._command} is already running, received signal to run it again." - ) - - elif not isinstance(raw_value, bool): - raise ValueError( - "Command PVs are `True` while the command is running, `False` once " - "it's finished. `False` can be put to stop the running command." - ) - if raw_value is True: - self._last_task = asyncio.create_task(self._run_command(pv)) + asyncio.create_task(self._run_command(pv)) await self._task_started_event.wait() # Flip to true once command task starts @@ -276,80 +252,179 @@ def make_command_pv(command: Callable) -> SharedPV: return shared_pv -AccessModeType = Literal["r", "w", "rw", "pvi", "command"] +AccessModeType = Literal["r", "w", "rw", "d", "x"] + +PviName = str -class _PviFieldInfo(TypedDict): +@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 -class _PviBlockDisplay(TypedDict): - description: str + # Number for the int value on the end of the pv. + # We need this so that a pvi group with Child1 and Child3 controllers gives + # structure[] child + # structure + # (none) + # structure + # string d P4P_TEST_DEVICE:Child1:PVI + # structure + # (none) + # structure + # string d P4P_TEST_DEVICE:Child3:PVI + number: int | None = None -class _PviBlockInfo(TypedDict): - display: _PviBlockDisplay - value: list[_PviFieldInfo] +@dataclass +class _PviBlockInfo: + field_infos: dict[str, list[_PviFieldInfo]] + description: str | None -class PviTree: - _P4PType = Type( - [ - ("alarm", alarm), - ("timeStamp", timeStamp), - ("display", ("S", None, [("description", "s")])), - ( - "value", - ("aS", None, [("pv", "s"), ("access", "s")]), - ), - ] - ) +def _pv_to_pvi_field(pv: str) -> tuple[str, int | None]: + leaf = pv.rsplit(":", maxsplit=1)[-1].lower() + match = re.search(r"(\d+)$", leaf) + number = int(match.group(1)) if match else None + string_without_number = re.sub(r"\d+$", "", leaf) + return string_without_number, number - def __init__(self): - self._pvi_info: dict[str, _PviBlockInfo] = {} - def add_block(self, block_pv: str, description: str | None = None): +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( - display=_PviBlockDisplay(description=(description or "")), value=[] + 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 ( - description is not None - and self._pvi_info[block_pv]["display"]["description"] != description + controller_t + is not ( + other_field := self._pvi_info[parent_block_pv].field_infos[pvi_name][-1] + ).controller_t ): - # Allows field info to be added before the block info. - # Not needed in the case of `controller.get_mappings`, - # but still useful. - self._pvi_info[block_pv]["display"]["description"] = description - - parent_pv = block_pv.rsplit(":", maxsplit=1)[0] - if parent_pv != block_pv: - self._pvi_info[parent_pv]["value"].append( - _PviFieldInfo(pv=f"{block_pv}:PVI", access="pvi") + 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}." ) - def add_field(self, attribute_pv: str, access: AccessModeType): - block_pv = attribute_pv.rsplit(":", maxsplit=1)[0] - if block_pv not in self._pvi_info: - self._pvi_info[block_pv] = _PviBlockInfo( - display=_PviBlockDisplay(description=""), value=[] + 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, ) - self._pvi_info[block_pv]["value"].append( - _PviFieldInfo(pv=attribute_pv, access=access) + ) + + def add_field( + self, + attribute_pv: str, + access: AccessModeType, + ): + pvi_name, _ = _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) ) def make_provider(self) -> StaticProvider: provider = StaticProvider("PVI") - for block_pv, block_pvi_info in self._pvi_info.items(): + + for block_pv, block_info in self._pvi_info.items(): provider.add( f"{block_pv}:PVI", - SharedPV(initial=self._p4p_value(block_pvi_info)), + SharedPV(initial=self._p4p_value(block_info)), ) return provider - def _p4p_value(self, block_pvi_info: _PviBlockInfo) -> Value: + 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 + } + } + return Value( - self._P4PType, - {**_p4p_alarm_states(), **_p4p_timestamp_now(), **block_pvi_info}, + p4p_type, + { + **_p4p_alarm_states(), + **_p4p_timestamp_now(), + "display": {"description": block_info.description}, + "value": value, + }, ) diff --git a/tests/conftest.py b/tests/conftest.py index 7169c8081..491d136de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,10 +65,10 @@ def pytest_internalerror(excinfo: pytest.ExceptionInfo[Any]): @pytest.fixture(scope="module") -def ioc(): +def softioc_subprocess(): TIMEOUT = 10 process = subprocess.Popen( - ["python", HERE / "ioc.py"], + ["python", HERE / "example_softioc.py"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -95,6 +95,30 @@ def ioc(): process.wait(TIMEOUT) +@pytest.fixture(scope="module") +def p4p_subprocess(): + TIMEOUT = 10 + process = subprocess.Popen( + ["python", HERE / "example_p4p_ioc.py"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + ) + + # close backend caches before the event loop + purge_channel_caches() + + yield + + # Close open files + for f in [process.stdin, process.stdout, process.stderr]: + if f: + f.close() + process.send_signal(signal.SIGINT) + process.wait(TIMEOUT) + + @pytest.fixture(scope="session") def tango_system(): subprocess.run( diff --git a/tests/p4p_ioc.py b/tests/example_p4p_ioc.py similarity index 80% rename from tests/p4p_ioc.py rename to tests/example_p4p_ioc.py index 2074cde39..abd7f4a5b 100644 --- a/tests/p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -24,6 +24,7 @@ class FEnum(enum.Enum): class ParentController(Controller): + description = "some controller" a: AttrR = AttrR(Int()) b: AttrRW = AttrRW(Float(min=-1, min_alarm=-0.5)) @@ -56,10 +57,15 @@ async def i(self): def run(): p4p_options = EpicsOptions( - ioc=EpicsIOCOptions(pv_prefix="DEVICE"), backend=EpicsBackend.P4P + ioc=EpicsIOCOptions(pv_prefix="P4P_TEST_DEVICE"), backend=EpicsBackend.P4P ) controller = ParentController() - controller.register_sub_controller("Child", ChildController()) + controller.register_sub_controller( + "Child1", ChildController(description="some sub controller") + ) + controller.register_sub_controller( + "Child2", ChildController(description="another sub controller") + ) fastcs = FastCS(controller, [p4p_options]) fastcs.run() diff --git a/tests/ioc.py b/tests/example_softioc.py similarity index 100% rename from tests/ioc.py rename to tests/example_softioc.py diff --git a/tests/transport/epics/p4p/test_p4p_system.py b/tests/transport/epics/p4p/test_p4p_system.py new file mode 100644 index 000000000..e04a80bc1 --- /dev/null +++ b/tests/transport/epics/p4p/test_p4p_system.py @@ -0,0 +1,36 @@ +from p4p import Value +from p4p.client.thread import Context + + +def test_ioc(p4p_subprocess: None): + ctxt = Context("pva") + + _parent_pvi = ctxt.get("P4P_TEST_DEVICE: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": "some controller"} + assert parent_pvi["value"] == { + "a": {"r": "P4P_TEST_DEVICE:A"}, + "b": {"rw": "P4P_TEST_DEVICE:B"}, + "child": [ + {"pvi": "P4P_TEST_DEVICE:Child1:PVI"}, + {"pvi": "P4P_TEST_DEVICE:Child2:PVI"}, + ], + } + + child_pvi_pv = parent_pvi["value"]["child"][0]["pvi"] + _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": "some sub controller"} + assert child_pvi["value"] == { + "c": {"w": "P4P_TEST_DEVICE:Child1:C"}, + "d": {"x": "P4P_TEST_DEVICE:Child1:D"}, + "e": {"r": "P4P_TEST_DEVICE:Child1:E"}, + "f": {"rw": "P4P_TEST_DEVICE:Child1:F"}, + "g": {"rw": "P4P_TEST_DEVICE:Child1:G"}, + "h": {"rw": "P4P_TEST_DEVICE:Child1:H"}, + "i": {"x": "P4P_TEST_DEVICE:Child1:I"}, + } diff --git a/tests/transport/epics/softioc/test_ioc.py b/tests/transport/epics/softioc/test_softioc.py similarity index 100% rename from tests/transport/epics/softioc/test_ioc.py rename to tests/transport/epics/softioc/test_softioc.py diff --git a/tests/transport/epics/softioc/test_ioc_system.py b/tests/transport/epics/softioc/test_softioc_system.py similarity index 96% rename from tests/transport/epics/softioc/test_ioc_system.py rename to tests/transport/epics/softioc/test_softioc_system.py index 78920e4b8..4f53268ab 100644 --- a/tests/transport/epics/softioc/test_ioc_system.py +++ b/tests/transport/epics/softioc/test_softioc_system.py @@ -2,7 +2,7 @@ from p4p.client.thread import Context -def test_ioc(ioc: None): +def test_ioc(softioc_subprocess: None): ctxt = Context("pva") _parent_pvi = ctxt.get("DEVICE:PVI") From 06db29d081fdd6ab739f6f22696380a7cb61faf5 Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Tue, 11 Feb 2025 15:46:22 +0000 Subject: [PATCH 4/8] Added `Table` type --- src/fastcs/datatypes.py | 28 +++++++++++- src/fastcs/transport/epics/p4p/types.py | 48 +++++++++++++++++--- tests/example_p4p_ioc.py | 6 ++- tests/transport/epics/p4p/test_p4p_system.py | 15 ++++-- 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py index 2cf258a14..2172e4175 100644 --- a/src/fastcs/datatypes.py +++ b/src/fastcs/datatypes.py @@ -10,7 +10,9 @@ import numpy as np from numpy.typing import DTypeLike -T = TypeVar("T", int, float, bool, str, enum.Enum, np.ndarray) +T = TypeVar( + "T", int, float, bool, str, enum.Enum, np.ndarray, list[tuple[str, DTypeLike]] +) ATTRIBUTE_TYPES: tuple[type] = T.__constraints__ # type: ignore @@ -172,3 +174,27 @@ def validate(self, value: np.ndarray) -> np.ndarray: f"{self.shape}" ) return value + + +@dataclass(frozen=True) +class Table(DataType[np.ndarray]): + # https://numpy.org/devdocs/user/basics.rec.html#structured-datatype-creation + structured_dtype: list[tuple[str, DTypeLike]] + + @property + def dtype(self) -> type[np.ndarray]: + return np.ndarray + + @property + def initial_value(self) -> np.ndarray: + return np.array([], dtype=self.structured_dtype) + + def validate(self, value: np.ndarray) -> np.ndarray: + super().validate(value) + + if self.structured_dtype != value.dtype: + raise ValueError( + f"Value dtype {value.dtype.descr} is not the same as the structured " + f"dtype {self.structured_dtype}" + ) + return value diff --git a/src/fastcs/transport/epics/p4p/types.py b/src/fastcs/transport/epics/p4p/types.py index 307b24eaa..e1d24ba9a 100644 --- a/src/fastcs/transport/epics/p4p/types.py +++ b/src/fastcs/transport/epics/p4p/types.py @@ -5,17 +5,19 @@ from dataclasses import asdict, dataclass from typing import Literal +import numpy as np +from numpy.typing import DTypeLike from p4p import Type, Value -from p4p.nt import NTEnum, NTNDArray, NTScalar +from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable from p4p.nt.common import alarm, timeStamp from p4p.server import ServerOperation, StaticProvider from p4p.server.asyncio import SharedPV from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controller import BaseController -from fastcs.datatypes import Bool, Enum, Float, Int, String, T, Waveform +from fastcs.datatypes import Bool, Enum, Float, Int, String, T, Table, Waveform -P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform) +P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform, Table) _P4P_EXTRA = [("description", ("u", None, [("defval", "s")]))] @@ -42,10 +44,31 @@ _MAJOR_ALARM_SEVERITY = 2 _NO_ALARM_SEVERITY = 0 +# https://numpy.org/devdocs/reference/arrays.dtypes.html#arrays-dtypes +# Some numpy dtypes don't match directly with the p4p ones +_NUMPY_DTYPE_TO_P4P_DTYPE = { + "S": "s", # Raw bytes to unicode bytes + "U": "s", +} + + +def _table_with_numpy_dtypes_to_p4p_dtypes(numpy_dtypes: list[tuple[str, DTypeLike]]): + 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." + ) + p4p_dtypes.append((name, dtype_char)) + return p4p_dtypes + def _get_nt_scalar_from_attribute( attribute: Attribute, -) -> NTScalar | NTEnum | NTNDArray: +) -> NTScalar | NTEnum | NTNDArray | NTTable: match attribute.datatype: case Int(): return _P4P_INT @@ -55,7 +78,7 @@ def _get_nt_scalar_from_attribute( return _P4P_STRING case Bool(): return _P4P_BOOL - # `NTEnum/NTNDArray.wrap` don't accept extra fields until + # `NTEnum/NTNDArray/NTTable.wrap` don't accept extra fields until # https://github.com/epics-base/p4p/issues/166 case Enum(): return NTEnum() @@ -68,6 +91,10 @@ def _get_nt_scalar_from_attribute( # use an NDArray here even if shape is 1D return NTNDArray() + case Table(structured_dtype): + return NTTable( + columns=_table_with_numpy_dtypes_to_p4p_dtypes(structured_dtype) + ) case _: raise RuntimeError(f"Datatype `{attribute.datatype}` unsupported in P4P.") @@ -137,6 +164,8 @@ def _cast_to_p4p_type(attribute: Attribute[T], value: T) -> object: } case Waveform(): return attribute.datatype.validate(value) + case Table(): + return attribute.datatype.validate(value) case datatype if issubclass(type(datatype), P4P_ALLOWED_DATATYPES): record_fields = {"value": datatype.validate(value)} @@ -168,7 +197,14 @@ def __init__(self, attr_w: AttrW | AttrRW): async def put(self, pv: SharedPV, op: ServerOperation): value = op.value() - raw_value = value.raw.value + if isinstance(value, list): + assert isinstance(self._attr_w.datatype, Table) + raw_value = np.array( + [tuple(labelled_row.values()) for labelled_row in value], + dtype=self._attr_w.datatype.structured_dtype, + ) + else: + raw_value = value.raw.value cast_value = _cast_from_p4p_type(self._attr_w, raw_value) await self._attr_w.process_without_display_update(cast_value) diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index abd7f4a5b..9c74fc63b 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -5,7 +5,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controller import Controller, SubController -from fastcs.datatypes import Bool, Enum, Float, Int, Waveform +from fastcs.datatypes import Bool, Enum, Float, Int, Table, Waveform from fastcs.launch import FastCS from fastcs.transport.epics.options import ( EpicsBackend, @@ -28,6 +28,10 @@ class ParentController(Controller): a: AttrR = AttrR(Int()) b: AttrRW = AttrRW(Float(min=-1, min_alarm=-0.5)) + table: AttrRW = AttrRW( + Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]) + ) + class ChildController(SubController): c: AttrW = AttrW(Int()) diff --git a/tests/transport/epics/p4p/test_p4p_system.py b/tests/transport/epics/p4p/test_p4p_system.py index e04a80bc1..c72687554 100644 --- a/tests/transport/epics/p4p/test_p4p_system.py +++ b/tests/transport/epics/p4p/test_p4p_system.py @@ -13,13 +13,18 @@ def test_ioc(p4p_subprocess: None): assert parent_pvi["value"] == { "a": {"r": "P4P_TEST_DEVICE:A"}, "b": {"rw": "P4P_TEST_DEVICE:B"}, - "child": [ - {"pvi": "P4P_TEST_DEVICE:Child1:PVI"}, - {"pvi": "P4P_TEST_DEVICE:Child2:PVI"}, - ], + "child": { + "d": { + "v1": "P4P_TEST_DEVICE:Child1:PVI", + "v2": "P4P_TEST_DEVICE:Child2:PVI", + } + }, + "table": { + "rw": "P4P_TEST_DEVICE:Table", + }, } - child_pvi_pv = parent_pvi["value"]["child"][0]["pvi"] + child_pvi_pv = parent_pvi["value"]["child"]["d"]["v1"] _child_pvi = ctxt.get(child_pvi_pv) assert isinstance(_child_pvi, Value) child_pvi = _child_pvi.todict() From c0c6dacf5dfd3e34a926c21f1223ad12276f414d Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Wed, 12 Feb 2025 10:03:22 +0000 Subject: [PATCH 5/8] Made test ioc subprocess propogate errors. --- src/fastcs/transport/epics/p4p/adapter.py | 1 + src/fastcs/transport/epics/softioc/adapter.py | 1 + tests/conftest.py | 125 ++++++++++++------ 3 files changed, 85 insertions(+), 42 deletions(-) diff --git a/src/fastcs/transport/epics/p4p/adapter.py b/src/fastcs/transport/epics/p4p/adapter.py index cb3054d92..fafb6c864 100644 --- a/src/fastcs/transport/epics/p4p/adapter.py +++ b/src/fastcs/transport/epics/p4p/adapter.py @@ -23,6 +23,7 @@ def options(self) -> EpicsOptions: return self._options async def serve(self) -> None: + print(f"Running FastCS IOC: {self._pv_prefix}") await self._ioc.run() def create_docs(self) -> None: diff --git a/src/fastcs/transport/epics/softioc/adapter.py b/src/fastcs/transport/epics/softioc/adapter.py index 34d91d461..b8f31c988 100644 --- a/src/fastcs/transport/epics/softioc/adapter.py +++ b/src/fastcs/transport/epics/softioc/adapter.py @@ -36,6 +36,7 @@ def create_gui(self) -> None: EpicsGUI(self._controller, self._pv_prefix).create_gui(self.options.gui) async def serve(self) -> None: + print(f"Running FastCS IOC: {self._pv_prefix}") self._ioc.run(self._loop) while True: await asyncio.sleep(1) diff --git a/tests/conftest.py b/tests/conftest.py index 491d136de..42148e0e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,14 @@ +import io +import multiprocessing import os import random import signal import string import subprocess +import sys import time +from collections.abc import Callable +from multiprocessing.context import DefaultContext from pathlib import Path from typing import Any @@ -20,6 +25,9 @@ TestUpdater, ) +from .example_p4p_ioc import run as _run_p4p_ioc +from .example_softioc import run as _run_softioc + DATA_PATH = Path(__file__).parent / "data" @@ -64,59 +72,92 @@ def pytest_internalerror(excinfo: pytest.ExceptionInfo[Any]): HERE = Path(os.path.dirname(os.path.abspath(__file__))) -@pytest.fixture(scope="module") -def softioc_subprocess(): - TIMEOUT = 10 - process = subprocess.Popen( - ["python", HERE / "example_softioc.py"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - ) +def run_softioc( + error_queue: multiprocessing.Queue, stdout_queue: multiprocessing.Queue +): + class QueueWriter(io.TextIOBase): + def __init__(self, queue): + self.queue = queue - start_time = time.monotonic() - while "iocRun: All initialization complete" not in ( - process.stdout.readline().strip() # type: ignore - ): - if time.monotonic() - start_time > TIMEOUT: - raise TimeoutError("IOC did not start in time") + def write(self, s): # type: ignore + self.queue.put(s) - yield + try: + sys.stdout = QueueWriter(stdout_queue) + _run_softioc() - # close backend caches before the event loop - purge_channel_caches() + except Exception as e: + error_queue.put(e) - # Close open files - for f in [process.stdin, process.stdout, process.stderr]: - if f: - f.close() - process.send_signal(signal.SIGINT) - process.wait(TIMEOUT) +def run_p4p_ioc( + error_queue: multiprocessing.Queue, stdout_queue: multiprocessing.Queue +): + class QueueWriter(io.TextIOBase): + def __init__(self, queue): + self.queue = queue -@pytest.fixture(scope="module") -def p4p_subprocess(): + def write(self, s): # type: ignore + self.queue.put(s) + + try: + sys.stdout = QueueWriter(stdout_queue) + _run_p4p_ioc() + + except Exception as e: + error_queue.put(e) + + +def run_ioc_as_subprocess(run_ioc: Callable, ctxt: DefaultContext): TIMEOUT = 10 - process = subprocess.Popen( - ["python", HERE / "example_p4p_ioc.py"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, + IOC_STARTUP_TIMEOUT_ERROR = TimeoutError("IOC did not start in time") + + error_queue = ctxt.Queue() + stdout_queue = ctxt.Queue() + process = ctxt.Process( + target=run_ioc, + args=(error_queue, stdout_queue), ) + process.start() + + try: + start_time = time.monotonic() + while True: + try: + if "Running FastCS IOC" in ( + stdout_queue.get(timeout=TIMEOUT) # type: ignore + ): + break + except Exception as error: + raise IOC_STARTUP_TIMEOUT_ERROR from error + if time.monotonic() - start_time > TIMEOUT: + raise IOC_STARTUP_TIMEOUT_ERROR + + yield + + # Propogate errors + if not error_queue.empty(): + raise error_queue.get() + finally: + # close backend caches before the event loop + purge_channel_caches() + + error_queue.close() + stdout_queue.close() + process.terminate() + process.join(timeout=TIMEOUT) - # close backend caches before the event loop - purge_channel_caches() - yield +@pytest.fixture(scope="module") +def p4p_subprocess(): + multiprocessing.set_start_method("forkserver", force=True) + yield from run_ioc_as_subprocess(run_p4p_ioc, multiprocessing.get_context()) - # Close open files - for f in [process.stdin, process.stdout, process.stderr]: - if f: - f.close() - process.send_signal(signal.SIGINT) - process.wait(TIMEOUT) + +@pytest.fixture(scope="module") +def softioc_subprocess(): + multiprocessing.set_start_method("spawn", force=True) + yield from run_ioc_as_subprocess(run_softioc, multiprocessing.get_context()) @pytest.fixture(scope="session") From 3e84ef213f0c3845df039c844783f863548da02c Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Wed, 12 Feb 2025 12:44:31 +0000 Subject: [PATCH 6/8] Added tests. --- pyproject.toml | 2 + src/fastcs/controller.py | 4 +- src/fastcs/transport/epics/p4p/handlers.py | 119 ++++ src/fastcs/transport/epics/p4p/ioc.py | 3 +- src/fastcs/transport/epics/p4p/pvi_tree.py | 197 +++++++ src/fastcs/transport/epics/p4p/types.py | 355 ++---------- tests/conftest.py | 66 ++- tests/example_p4p_ioc.py | 20 +- tests/example_softioc.py | 4 +- tests/transport/epics/p4p/test_p4p.py | 516 ++++++++++++++++++ tests/transport/epics/p4p/test_p4p_system.py | 41 -- .../epics/softioc/test_softioc_system.py | 17 +- 12 files changed, 936 insertions(+), 408 deletions(-) create mode 100644 src/fastcs/transport/epics/p4p/handlers.py create mode 100644 src/fastcs/transport/epics/p4p/pvi_tree.py create mode 100644 tests/transport/epics/p4p/test_p4p.py delete mode 100644 tests/transport/epics/p4p/test_p4p_system.py diff --git a/pyproject.toml b/pyproject.toml index 4058d83b1..3b70143cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,8 @@ testpaths = "docs src tests" [tool.coverage.run] data_file = "/tmp/fastcs.coverage" +concurrency = ["thread", "multiprocessing"] +omit = ["tests/*"] [tool.coverage.paths] # Tests are run from installed location, map back to the src directory diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 867abdfee..0648b4326 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -28,7 +28,9 @@ class BaseController: def __init__( self, path: list[str] | None = None, description: str | None = None ) -> None: - if self.description is None: + if ( + description is not None + ): # Use the argument over the one class defined description. self.description = description if not hasattr(self, "attributes"): diff --git a/src/fastcs/transport/epics/p4p/handlers.py b/src/fastcs/transport/epics/p4p/handlers.py new file mode 100644 index 000000000..ed703fbfc --- /dev/null +++ b/src/fastcs/transport/epics/p4p/handlers.py @@ -0,0 +1,119 @@ +import asyncio +import time +from collections.abc import Callable + +import numpy as np +from p4p.nt import NTScalar +from p4p.server import ServerOperation +from p4p.server.asyncio import SharedPV + +from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.datatypes import Table + +from .types import ( + MAJOR_ALARM_SEVERITY, + RECORD_ALARM_STATUS, + cast_from_p4p_value, + cast_to_p4p_value, + get_p4p_type, + p4p_alarm_states, +) + + +class AttrWHandler: + 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) + raw_value = np.array( + [tuple(labelled_row.values()) for labelled_row in value], + dtype=self._attr_w.datatype.structured_dtype, + ) + else: + raw_value = value.raw.value + + cast_value = cast_from_p4p_value(self._attr_w, raw_value) + + await self._attr_w.process_without_display_update(cast_value) + if type(self._attr_w) is AttrW: + # For AttrRW a post is done from the `_process_callback`. + pv.post(cast_to_p4p_value(self._attr_w, cast_value)) + op.done() + + +class CommandHandler: + def __init__(self, command: Callable): + self._command = command + self._task_started_event = asyncio.Event() + + async def _run_command(self, pv: SharedPV): + self._task_started_event.set() + self._task_started_event.clear() + + kwargs = {} + try: + await self._command() + except Exception as e: + kwargs.update( + p4p_alarm_states(MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, str(e)) + ) + else: + kwargs.update(p4p_alarm_states()) + + value = NTScalar("?").wrap({"value": False, **kwargs}) + timestamp = time.time() + pv.close() + pv.open(value, timestamp=timestamp) + pv.post(value, timestamp=timestamp) + + async def put(self, pv: SharedPV, op: ServerOperation): + value = op.value() + raw_value = value.raw.value + + if raw_value is True: + asyncio.create_task(self._run_command(pv)) + await self._task_started_event.wait() + + # Flip to true once command task starts + pv.post(value, timestamp=time.time()) + op.done() + + +def make_shared_pv(attribute: Attribute) -> SharedPV: + initial_value = ( + attribute.get() + 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) + + shared_pv = SharedPV(**kwargs) + + if isinstance(attribute, (AttrR | AttrRW)): + shared_pv.post(cast_to_p4p_value(attribute, attribute.get())) + + async def on_update(value): + shared_pv.post(cast_to_p4p_value(attribute, value)) + + attribute.set_update_callback(on_update) + + return shared_pv + + +def make_command_pv(command: Callable) -> SharedPV: + shared_pv = SharedPV( + nt=NTScalar("?"), + initial=False, + handler=CommandHandler(command), + ) + + return shared_pv diff --git a/src/fastcs/transport/epics/p4p/ioc.py b/src/fastcs/transport/epics/p4p/ioc.py index 334f39758..06ef44cae 100644 --- a/src/fastcs/transport/epics/p4p/ioc.py +++ b/src/fastcs/transport/epics/p4p/ioc.py @@ -6,7 +6,8 @@ from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controller import Controller -from .types import AccessModeType, PviTree, make_command_pv, make_shared_pv +from .handlers import make_command_pv, make_shared_pv +from .pvi_tree import AccessModeType, PviTree _attr_to_access: dict[type[Attribute], AccessModeType] = { AttrR: "r", diff --git a/src/fastcs/transport/epics/p4p/pvi_tree.py b/src/fastcs/transport/epics/p4p/pvi_tree.py new file mode 100644 index 000000000..bf81d8fa8 --- /dev/null +++ b/src/fastcs/transport/epics/p4p/pvi_tree.py @@ -0,0 +1,197 @@ +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/p4p/types.py b/src/fastcs/transport/epics/p4p/types.py index e1d24ba9a..850375326 100644 --- a/src/fastcs/transport/epics/p4p/types.py +++ b/src/fastcs/transport/epics/p4p/types.py @@ -1,20 +1,12 @@ -import asyncio -import re +import math import time -from collections.abc import Callable -from dataclasses import asdict, dataclass -from typing import Literal +from dataclasses import asdict import numpy as np from numpy.typing import DTypeLike -from p4p import Type, Value from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable -from p4p.nt.common import alarm, timeStamp -from p4p.server import ServerOperation, StaticProvider -from p4p.server.asyncio import SharedPV -from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.controller import BaseController +from fastcs.attributes import Attribute from fastcs.datatypes import Bool, Enum, Float, Int, String, T, Table, Waveform P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform, Table) @@ -39,10 +31,10 @@ # https://epics-base.github.io/pvxs/nt.html#alarm-t -_RECORD_ALARM_STATUS = 3 -_NO_ALARM_STATUS = 0 -_MAJOR_ALARM_SEVERITY = 2 -_NO_ALARM_SEVERITY = 0 +RECORD_ALARM_STATUS = 3 +NO_ALARM_STATUS = 0 +MAJOR_ALARM_SEVERITY = 2 +NO_ALARM_SEVERITY = 0 # https://numpy.org/devdocs/reference/arrays.dtypes.html#arrays-dtypes # Some numpy dtypes don't match directly with the p4p ones @@ -66,7 +58,7 @@ def _table_with_numpy_dtypes_to_p4p_dtypes(numpy_dtypes: list[tuple[str, DTypeLi return p4p_dtypes -def _get_nt_scalar_from_attribute( +def get_p4p_type( attribute: Attribute, ) -> NTScalar | NTEnum | NTNDArray | NTTable: match attribute.datatype: @@ -78,20 +70,21 @@ def _get_nt_scalar_from_attribute( return _P4P_STRING case Bool(): return _P4P_BOOL - # `NTEnum/NTNDArray/NTTable.wrap` don't accept extra fields until - # https://github.com/epics-base/p4p/issues/166 case Enum(): return NTEnum() case Waveform(): - # TODO: Make 1D scalar array for 1D shapes - # This will require converting from np.int32 to "ai" - # if len(shape) == 1: - # return NTScalarArray(convert np.datatype32 to string "ad") - # TODO: Add an option for allowing shape to change, if so we will - # use an NDArray here even if shape is 1D + # TODO: https://github.com/DiamondLightSource/FastCS/issues/123 + # * Make 1D scalar array for 1D shapes. + # This will require converting from np.int32 to "ai" + # if len(shape) == 1: + # return NTScalarArray(convert np.datatype32 to string "ad") + # * Add an option for allowing shape to change, if so we will + # use an NDArray here even if shape is 1D return NTNDArray() case Table(structured_dtype): + # TODO: `NTEnum/NTNDArray/NTTable.wrap` don't accept extra fields until + # https://github.com/epics-base/p4p/issues/166 return NTTable( columns=_table_with_numpy_dtypes_to_p4p_dtypes(structured_dtype) ) @@ -99,10 +92,17 @@ def _get_nt_scalar_from_attribute( raise RuntimeError(f"Datatype `{attribute.datatype}` unsupported in P4P.") -def _cast_from_p4p_type(attribute: Attribute[T], value: object) -> T: +def cast_from_p4p_value(attribute: Attribute[T], value: object) -> T: match attribute.datatype: case Enum(): return attribute.datatype.validate(attribute.datatype.members[value.index]) + case Waveform(shape=shape): + # p4p sends a flattened array + assert value.shape == (math.prod(shape),) + return attribute.datatype.validate(value.reshape(attribute.datatype.shape)) + case Table(structured_dtype): + assert isinstance(value, np.ndarray) + return attribute.datatype.validate(np.array(value, dtype=structured_dtype)) case attribute.datatype if issubclass( type(attribute.datatype), P4P_ALLOWED_DATATYPES ): @@ -111,9 +111,9 @@ def _cast_from_p4p_type(attribute: Attribute[T], value: object) -> T: raise ValueError(f"Unsupported datatype {attribute.datatype}") -def _p4p_alarm_states( - severity: int = _NO_ALARM_SEVERITY, - status: int = _NO_ALARM_STATUS, +def p4p_alarm_states( + severity: int = NO_ALARM_SEVERITY, + status: int = NO_ALARM_STATUS, message: str = "", ) -> dict: return { @@ -125,7 +125,7 @@ def _p4p_alarm_states( } -def _p4p_timestamp_now() -> dict: +def p4p_timestamp_now() -> dict: now = time.time() seconds_past_epoch = int(now) nanoseconds = int((now - seconds_past_epoch) * 1e9) @@ -143,19 +143,19 @@ def _p4p_check_numerical_for_alarm_states( 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 severity = ( - _MAJOR_ALARM_SEVERITY - if high is not None or low is not None - else _NO_ALARM_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, "Below minimum." if high: - status, message = _RECORD_ALARM_STATUS, "Above maximum." - return _p4p_alarm_states(severity, status, message) + status, message = RECORD_ALARM_STATUS, "Above maximum." + return p4p_alarm_states(severity, status, message) -def _cast_to_p4p_type(attribute: Attribute[T], value: T) -> object: +def cast_to_p4p_value(attribute: Attribute[T], value: T) -> object: match attribute.datatype: case Enum(): return { @@ -180,287 +180,12 @@ def _cast_to_p4p_type(attribute: Attribute[T], value: T) -> object: ) ) else: - record_fields.update(_p4p_alarm_states()) + 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_nt_scalar_from_attribute(attribute).wrap(record_fields) # type: ignore + record_fields.update(p4p_timestamp_now()) + return get_p4p_type(attribute).wrap(record_fields) # type: ignore case _: raise ValueError(f"Unsupported datatype {attribute.datatype}") - - -class AttrWHandler: - 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) - raw_value = np.array( - [tuple(labelled_row.values()) for labelled_row in value], - dtype=self._attr_w.datatype.structured_dtype, - ) - else: - raw_value = value.raw.value - - cast_value = _cast_from_p4p_type(self._attr_w, raw_value) - await self._attr_w.process_without_display_update(cast_value) - - pv.post(_cast_to_p4p_type(self._attr_w, cast_value)) - op.done() - - -def make_shared_pv(attribute: Attribute) -> SharedPV: - initial_value = ( - attribute.get() - if isinstance(attribute, AttrRW | AttrR) - else attribute.datatype.initial_value - ) - kwargs = { - "nt": _get_nt_scalar_from_attribute(attribute), - "initial": _cast_to_p4p_type(attribute, initial_value), - } - - if isinstance(attribute, (AttrW | AttrRW)): - kwargs["handler"] = AttrWHandler(attribute) - - shared_pv = SharedPV(**kwargs) - - if isinstance(attribute, (AttrR | AttrRW)): - shared_pv.post(_cast_to_p4p_type(attribute, attribute.get())) - - async def on_update(value): - shared_pv.post(_cast_to_p4p_type(attribute, value)) - - attribute.set_update_callback(on_update) - - return shared_pv - - -class CommandHandler: - def __init__(self, command: Callable): - self._command = command - self._task_started_event = asyncio.Event() - - async def _run_command(self, pv: SharedPV): - self._task_started_event.set() - self._task_started_event.clear() - - kwargs = {} - try: - await self._command() - except Exception as e: - kwargs.update( - _p4p_alarm_states(_MAJOR_ALARM_SEVERITY, _RECORD_ALARM_STATUS, str(e)) - ) - else: - kwargs.update(_p4p_alarm_states()) - - value = NTScalar("?").wrap({"value": False, **kwargs}) - timestamp = time.time() - pv.close() - pv.open(value, timestamp=timestamp) - pv.post(value, timestamp=timestamp) - - async def put(self, pv: SharedPV, op: ServerOperation): - value = op.value() - raw_value = value.raw.value - - if raw_value is True: - asyncio.create_task(self._run_command(pv)) - await self._task_started_event.wait() - - # Flip to true once command task starts - pv.post(value, timestamp=time.time()) - op.done() - - -def make_command_pv(command: Callable) -> SharedPV: - shared_pv = SharedPV( - nt=NTScalar("?"), - initial=False, - handler=CommandHandler(command), - ) - - return shared_pv - - -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. - # We need this so that a pvi group with Child1 and Child3 controllers gives - # structure[] child - # structure - # (none) - # structure - # string d P4P_TEST_DEVICE:Child1:PVI - # structure - # (none) - # structure - # string d P4P_TEST_DEVICE:Child3:PVI - number: int | None = None - - -@dataclass -class _PviBlockInfo: - field_infos: dict[str, list[_PviFieldInfo]] - description: str | None - - -def _pv_to_pvi_field(pv: str) -> tuple[str, int | None]: - leaf = pv.rsplit(":", maxsplit=1)[-1].lower() - match = re.search(r"(\d+)$", leaf) - number = int(match.group(1)) if match else None - string_without_number = re.sub(r"\d+$", "", leaf) - return string_without_number, number - - -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, _ = _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) - ) - - 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 - } - } - - return Value( - p4p_type, - { - **_p4p_alarm_states(), - **_p4p_timestamp_now(), - "display": {"description": block_info.description}, - "value": value, - }, - ) diff --git a/tests/conftest.py b/tests/conftest.py index 42148e0e0..a28c8af89 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,10 +7,11 @@ import subprocess import sys import time -from collections.abc import Callable +from collections.abc import Callable, Generator from multiprocessing.context import DefaultContext from pathlib import Path from typing import Any +from uuid import uuid4 import pytest from aioca import purge_channel_caches @@ -24,9 +25,8 @@ TestSender, TestUpdater, ) - -from .example_p4p_ioc import run as _run_p4p_ioc -from .example_softioc import run as _run_softioc +from tests.example_p4p_ioc import run as _run_p4p_ioc +from tests.example_softioc import run as _run_softioc DATA_PATH = Path(__file__).parent / "data" @@ -72,27 +72,19 @@ def pytest_internalerror(excinfo: pytest.ExceptionInfo[Any]): HERE = Path(os.path.dirname(os.path.abspath(__file__))) -def run_softioc( - error_queue: multiprocessing.Queue, stdout_queue: multiprocessing.Queue +def _run_ioc_as_subprocess( + pv_prefix: str, + run_ioc: Callable, + error_queue: multiprocessing.Queue, + stdout_queue: multiprocessing.Queue, ): - class QueueWriter(io.TextIOBase): - def __init__(self, queue): - self.queue = queue - - def write(self, s): # type: ignore - self.queue.put(s) - try: - sys.stdout = QueueWriter(stdout_queue) - _run_softioc() + from pytest_cov.embed import cleanup_on_sigterm + except ImportError: + ... + else: + cleanup_on_sigterm() - except Exception as e: - error_queue.put(e) - - -def run_p4p_ioc( - error_queue: multiprocessing.Queue, stdout_queue: multiprocessing.Queue -): class QueueWriter(io.TextIOBase): def __init__(self, queue): self.queue = queue @@ -102,21 +94,24 @@ def write(self, s): # type: ignore try: sys.stdout = QueueWriter(stdout_queue) - _run_p4p_ioc() + run_ioc(pv_prefix=pv_prefix) except Exception as e: error_queue.put(e) -def run_ioc_as_subprocess(run_ioc: Callable, ctxt: DefaultContext): - TIMEOUT = 10 +def run_ioc_as_subprocess( + run_ioc: Callable, ctxt: DefaultContext +) -> Generator[tuple[str, multiprocessing.Queue], None, None]: + IOC_STARTUP_TIMEOUT = 10 IOC_STARTUP_TIMEOUT_ERROR = TimeoutError("IOC did not start in time") + pv_prefix = str(uuid4()) error_queue = ctxt.Queue() stdout_queue = ctxt.Queue() process = ctxt.Process( - target=run_ioc, - args=(error_queue, stdout_queue), + target=_run_ioc_as_subprocess, + args=(pv_prefix, run_ioc, error_queue, stdout_queue), ) process.start() @@ -125,39 +120,42 @@ def run_ioc_as_subprocess(run_ioc: Callable, ctxt: DefaultContext): while True: try: if "Running FastCS IOC" in ( - stdout_queue.get(timeout=TIMEOUT) # type: ignore + stdout_queue.get(timeout=IOC_STARTUP_TIMEOUT) # type: ignore ): + stdout_queue.get() # get the newline break except Exception as error: raise IOC_STARTUP_TIMEOUT_ERROR from error - if time.monotonic() - start_time > TIMEOUT: + if time.monotonic() - start_time > IOC_STARTUP_TIMEOUT: raise IOC_STARTUP_TIMEOUT_ERROR - yield + time.sleep(0.1) + yield pv_prefix, stdout_queue + finally: # Propogate errors if not error_queue.empty(): raise error_queue.get() - finally: + # close backend caches before the event loop purge_channel_caches() error_queue.close() stdout_queue.close() process.terminate() - process.join(timeout=TIMEOUT) + process.join(timeout=IOC_STARTUP_TIMEOUT) @pytest.fixture(scope="module") def p4p_subprocess(): multiprocessing.set_start_method("forkserver", force=True) - yield from run_ioc_as_subprocess(run_p4p_ioc, multiprocessing.get_context()) + yield from run_ioc_as_subprocess(_run_p4p_ioc, multiprocessing.get_context()) @pytest.fixture(scope="module") def softioc_subprocess(): multiprocessing.set_start_method("spawn", force=True) - yield from run_ioc_as_subprocess(run_softioc, multiprocessing.get_context()) + yield from run_ioc_as_subprocess(_run_softioc, multiprocessing.get_context()) @pytest.fixture(scope="session") diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index 9c74fc63b..a150b610f 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -25,8 +25,8 @@ class FEnum(enum.Enum): class ParentController(Controller): description = "some controller" - a: AttrR = AttrR(Int()) - b: AttrRW = AttrRW(Float(min=-1, min_alarm=-0.5)) + a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) + b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5)) table: AttrRW = AttrRW( Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]) @@ -34,12 +34,13 @@ class ParentController(Controller): class ChildController(SubController): + fail_on_next_e = True c: AttrW = AttrW(Int()) @command() async def d(self): print("D: RUNNING") - await asyncio.sleep(1) + await asyncio.sleep(0.1) print("D: FINISHED") e: AttrR = AttrR(Bool()) @@ -55,13 +56,18 @@ async def flip_flop(self): @command() async def i(self): print("I: RUNNING") - await asyncio.sleep(1) - raise RuntimeError("I: FAILED WITH THIS WEIRD ERROR") + await asyncio.sleep(0.1) + if self.fail_on_next_e: + self.fail_on_next_e = False + raise RuntimeError("I: FAILED WITH THIS WEIRD ERROR") + else: + self.fail_on_next_e = True + print("I: FINISHED") -def run(): +def run(pv_prefix="P4P_TEST_DEVICE"): p4p_options = EpicsOptions( - ioc=EpicsIOCOptions(pv_prefix="P4P_TEST_DEVICE"), backend=EpicsBackend.P4P + ioc=EpicsIOCOptions(pv_prefix=pv_prefix), backend=EpicsBackend.P4P ) controller = ParentController() controller.register_sub_controller( diff --git a/tests/example_softioc.py b/tests/example_softioc.py index 157750a81..1ff17d2b7 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -19,9 +19,9 @@ async def d(self): pass -def run(): +def run(pv_prefix="SOFTIOC_TEST_DEVICE"): epics_options = EpicsOptions( - ioc=EpicsIOCOptions(pv_prefix="DEVICE"), backend=EpicsBackend.SOFT_IOC + ioc=EpicsIOCOptions(pv_prefix=pv_prefix), backend=EpicsBackend.SOFT_IOC ) controller = ParentController() controller.register_sub_controller("Child", ChildController()) diff --git a/tests/transport/epics/p4p/test_p4p.py b/tests/transport/epics/p4p/test_p4p.py new file mode 100644 index 000000000..31e12b4d4 --- /dev/null +++ b/tests/transport/epics/p4p/test_p4p.py @@ -0,0 +1,516 @@ +import asyncio +import enum +from multiprocessing import Queue +from unittest.mock import ANY +from uuid import uuid4 + +import numpy as np +import pytest +from numpy.typing import DTypeLike +from p4p import Value +from p4p.client.asyncio import Context +from p4p.client.thread import Context as ThreadContext +from p4p.nt import NTTable + +from fastcs.attributes import AttrR, AttrRW, AttrW +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 + + +@pytest.mark.asyncio +async def test_ioc(p4p_subprocess: tuple[str, Queue]): + pv_prefix, _ = p4p_subprocess + ctxt = Context("pva") + + _parent_pvi = await 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": "some controller"} + assert parent_pvi["value"] == { + "a": {"rw": f"{pv_prefix}:A"}, + "b": {"w": f"{pv_prefix}:B"}, + "child": { + "d": { + "v1": f"{pv_prefix}:Child1:PVI", + "v2": f"{pv_prefix}:Child2:PVI", + } + }, + "table": { + "rw": f"{pv_prefix}:Table", + }, + } + + child_pvi_pv = parent_pvi["value"]["child"]["d"]["v1"] + _child_pvi = await 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": "some sub controller"} + assert child_pvi["value"] == { + "c": {"w": f"{pv_prefix}:Child1:C"}, + "d": {"x": f"{pv_prefix}:Child1:D"}, + "e": {"r": f"{pv_prefix}:Child1:E"}, + "f": {"rw": f"{pv_prefix}:Child1:F"}, + "g": {"rw": f"{pv_prefix}:Child1:G"}, + "h": {"rw": f"{pv_prefix}:Child1:H"}, + "i": {"x": f"{pv_prefix}:Child1:I"}, + } + + +@pytest.mark.asyncio +async def test_scan_method(p4p_subprocess: tuple[str, Queue]): + pv_prefix, _ = p4p_subprocess + ctxt = Context("pva") + e_values = asyncio.Queue() + + # While the scan method will update every 0.1 seconds, it takes around that + # time for the p4p backends to update, broadcast, get. + LATENCY = 1e8 + + e_monitor = ctxt.monitor(f"{pv_prefix}:Child1:E", e_values.put) + try: + # Throw away the value on the ioc setup so we can compare timestamps + _ = await e_values.get() + + raw_value = (await e_values.get()).raw + value = raw_value["value"] + assert isinstance(value, bool) + nanoseconds = raw_value["timeStamp"]["nanoseconds"] + + new_raw_value = (await e_values.get()).raw + assert new_raw_value["value"] is not value + assert new_raw_value["timeStamp"]["nanoseconds"] == pytest.approx( + nanoseconds + 1e8, abs=LATENCY + ) + value = new_raw_value["value"] + assert isinstance(value, bool) + nanoseconds = new_raw_value["timeStamp"]["nanoseconds"] + + new_raw_value = (await e_values.get()).raw + assert new_raw_value["value"] is not value + assert new_raw_value["timeStamp"]["nanoseconds"] == pytest.approx( + nanoseconds + 1e8, abs=LATENCY + ) + + finally: + e_monitor.close() + + +@pytest.mark.asyncio +async def test_command_method(p4p_subprocess: tuple[str, Queue]): + QUEUE_TIMEOUT = 1 + pv_prefix, stdout_queue = p4p_subprocess + d_values = asyncio.Queue() + i_values = asyncio.Queue() + ctxt = Context("pva") + + d_monitor = ctxt.monitor(f"{pv_prefix}:Child1:D", d_values.put) + i_monitor = ctxt.monitor(f"{pv_prefix}:Child1:I", i_values.put) + + try: + if not stdout_queue.empty(): + raise RuntimeError("stdout_queue not empty", stdout_queue.get()) + assert (await d_values.get()).raw.value is False + await ctxt.put(f"{pv_prefix}:Child1:D", True) + assert (await d_values.get()).raw.value is True + await asyncio.sleep(0.2) + assert (await d_values.get()).raw.value is False + + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "D: RUNNING" + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "D: FINISHED" + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" + + # First run fails + assert stdout_queue.empty() + before_command_value = (await i_values.get()).raw + assert before_command_value["value"] is False + assert before_command_value["alarm"]["severity"] == 0 + assert before_command_value["alarm"]["message"] == "" + await ctxt.put(f"{pv_prefix}:Child1:I", True) + assert (await i_values.get()).raw.value is True + await asyncio.sleep(0.2) + + after_command_value = (await i_values.get()).raw + assert after_command_value["value"] is False + assert after_command_value["alarm"]["severity"] == 2 + assert ( + after_command_value["alarm"]["message"] == "I: FAILED WITH THIS WEIRD ERROR" + ) + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "I: RUNNING" + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" + + # Second run succeeds + assert stdout_queue.empty() + await ctxt.put(f"{pv_prefix}:Child1:I", True) + assert (await i_values.get()).raw.value is True + await asyncio.sleep(0.2) + after_command_value = (await i_values.get()).raw + + # On the second run the command succeeded so we left the error state + assert after_command_value["value"] is False + assert after_command_value["alarm"]["severity"] == 0 + assert after_command_value["alarm"]["message"] == "" + + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "I: RUNNING" + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "I: FINISHED" + assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" + assert stdout_queue.empty() + + finally: + d_monitor.close() + i_monitor.close() + + +@pytest.mark.asyncio +async def test_numerical_alarms(p4p_subprocess: tuple[str, Queue]): + pv_prefix, _ = p4p_subprocess + a_values = asyncio.Queue() + b_values = asyncio.Queue() + ctxt = Context("pva") + + a_monitor = ctxt.monitor(f"{pv_prefix}:A", a_values.put) + b_monitor = ctxt.monitor(f"{pv_prefix}:B", b_values.put) + + try: + value = (await a_values.get()).raw + assert value["value"] == 0 + assert isinstance(value["value"], int) + assert value["alarm"]["severity"] == 0 + 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." + + await ctxt.put(f"{pv_prefix}:A", 40_001) + await ctxt.put(f"{pv_prefix}:B", -0.6) + + value = (await a_values.get()).raw + assert value["value"] == 40_001 + assert isinstance(value["value"], int) + assert value["alarm"]["severity"] == 2 + assert value["alarm"]["message"] == "Above maximum." + + 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." + + await ctxt.put(f"{pv_prefix}:A", 40_000) + await ctxt.put(f"{pv_prefix}:B", -0.5) + + value = (await a_values.get()).raw + assert value["value"] == 40_000 + assert isinstance(value["value"], int) + assert value["alarm"]["severity"] == 0 + 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 a_values.empty() + assert b_values.empty() + + finally: + a_monitor.close() + b_monitor.close() + + +def make_fastcs(pv_prefix: str, controller: Controller) -> FastCS: + epics_options = EpicsOptions( + ioc=EpicsIOCOptions(pv_prefix=pv_prefix), backend=EpicsBackend.P4P + ) + return FastCS(controller, [epics_options]) + + +def test_read_signal_set(): + class SomeController(Controller): + a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) + b: AttrR = AttrR(Float(min=-1, min_alarm=-0.5, prec=2)) + + controller = SomeController() + pv_prefix = str(uuid4()) + fastcs = make_fastcs(pv_prefix, controller) + + ctxt = ThreadContext("pva") + + async def _wait_and_set_attr_r(): + await asyncio.sleep(0.05) + await controller.a.set(40_000) + await controller.b.set(-0.99) + await asyncio.sleep(0.05) + await controller.a.set(-100) + await controller.b.set(-0.99) + await controller.b.set(-0.9111111) + + a_values, b_values = [], [] + a_monitor = ctxt.monitor(f"{pv_prefix}:A", a_values.append) + b_monitor = ctxt.monitor(f"{pv_prefix}:B", b_values.append) + serve = asyncio.ensure_future(fastcs.serve()) + wait_and_set_attr_r = asyncio.ensure_future(_wait_and_set_attr_r()) + try: + asyncio.get_event_loop().run_until_complete( + asyncio.wait_for(asyncio.gather(serve, wait_and_set_attr_r), timeout=0.2) + ) + except TimeoutError: + ... + finally: + a_monitor.close() + b_monitor.close() + serve.cancel() + wait_and_set_attr_r.cancel() + assert a_values == [0, 40_000, -100] + assert b_values == [0.0, -0.99, -0.99, -0.91] # Last is -0.91 because of prec + + +def test_pvi_grouping(): + class ChildChildController(SubController): + attr_d: AttrR = AttrR(String()) + + class ChildController(SubController): + attr_c: AttrW = AttrW(Bool(), description="Some bool") + + class SomeController(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()) + + controller = SomeController() + + sub_controller = ChildController() + controller.register_sub_controller("Child0", sub_controller) + sub_controller.register_sub_controller("ChildChild", ChildChildController()) + sub_controller = ChildController() + controller.register_sub_controller("Child1", sub_controller) + sub_controller.register_sub_controller("ChildChild", ChildChildController()) + sub_controller = ChildController() + controller.register_sub_controller("Child2", sub_controller) + sub_controller.register_sub_controller("ChildChild", ChildChildController()) + sub_controller = ChildController() + controller.register_sub_controller("another_child", sub_controller) + sub_controller.register_sub_controller("ChildChild", ChildChildController()) + sub_controller = ChildController() + controller.register_sub_controller("AdditionalChild", sub_controller) + sub_controller.register_sub_controller("ChildChild", ChildChildController()) + + pv_prefix = str(uuid4()) + fastcs = make_fastcs(pv_prefix, controller) + + ctxt = ThreadContext("pva") + + controller_pvi, child_controller_pvi, child_child_controller_pvi = [], [], [] + controller_monitor = ctxt.monitor(f"{pv_prefix}:PVI", controller_pvi.append) + child_controller_monitor = ctxt.monitor( + f"{pv_prefix}:Child0:PVI", child_controller_pvi.append + ) + child_child_controller_monitor = ctxt.monitor( + f"{pv_prefix}:Child0:ChildChild:PVI", child_child_controller_pvi.append + ) + serve = asyncio.ensure_future(fastcs.serve()) + + try: + asyncio.get_event_loop().run_until_complete( + asyncio.wait_for(serve, timeout=0.2) + ) + except TimeoutError: + ... + finally: + controller_monitor.close() + child_controller_monitor.close() + child_child_controller_monitor.close() + serve.cancel() + + assert len(controller_pvi) == 1 + assert controller_pvi[0].todict() == { + "alarm": {"message": "", "severity": 0, "status": 0}, + "display": {"description": ""}, + "timeStamp": { + "nanoseconds": ANY, + "secondsPastEpoch": ANY, + "userTag": 0, + }, + "value": { + "additional_child": {"d": f"{pv_prefix}:AdditionalChild:PVI"}, + "another_child": {"d": f"{pv_prefix}:another_child:PVI"}, + "another_attr": { + "rw": { + "v0": f"{pv_prefix}:AnotherAttr0", + "v1000": f"{pv_prefix}:AnotherAttr1000", + } + }, + "a_third_attr": {"w": f"{pv_prefix}:AThirdAttr"}, + "attr": {"rw": f"{pv_prefix}:Attr1"}, + "child": { + "d": { + "v0": f"{pv_prefix}:Child0:PVI", + "v1": f"{pv_prefix}:Child1:PVI", + "v2": f"{pv_prefix}:Child2:PVI", + } + }, + }, + } + assert len(child_controller_pvi) == 1 + assert child_controller_pvi[0].todict() == { + "alarm": {"message": "", "severity": 0, "status": 0}, + "display": {"description": ""}, + "timeStamp": { + "nanoseconds": ANY, + "secondsPastEpoch": ANY, + "userTag": 0, + }, + "value": { + "attr_c": {"w": f"{pv_prefix}:Child0:AttrC"}, + "child_child": {"d": f"{pv_prefix}:Child0:ChildChild:PVI"}, + }, + } + assert len(child_child_controller_pvi) == 1 + assert child_child_controller_pvi[0].todict() == { + "alarm": {"message": "", "severity": 0, "status": 0}, + "display": {"description": ""}, + "timeStamp": { + "nanoseconds": ANY, + "secondsPastEpoch": ANY, + "userTag": 0, + }, + "value": {"attr_d": {"r": f"{pv_prefix}:Child0:ChildChild:AttrD"}}, + } + + +def test_more_exotic_dataypes(): + table_columns: list[tuple[str, DTypeLike]] = [ + ("A", "i"), + ("B", "i"), + ("C", "?"), + ("D", "f"), + ] + + class AnEnum(enum.Enum): + A = 1 + B = 0 + C = 3 + + class SomeController(Controller): + some_waveform: AttrRW = AttrRW(Waveform(np.int64, shape=(10, 10))) + some_table: AttrRW = AttrRW(Table(table_columns)) + some_enum: AttrRW = AttrRW(Enum(AnEnum)) + + controller = SomeController() + pv_prefix = str(uuid4()) + fastcs = make_fastcs(pv_prefix, controller) + + ctxt = ThreadContext("pva") + + initial_waveform_value = np.zeros((10, 10), dtype=np.int64) + initial_table_value = np.array([], dtype=table_columns) + initial_enum_value = AnEnum.A + + 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_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}, + ] + ) + 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) + + async def _wait_and_put_pvs(): + await asyncio.sleep(0.3) + ctxt = Context("pva") + await asyncio.gather( + ctxt.put(f"{pv_prefix}:SomeWaveform", client_put_waveform_value), + ctxt.put(f"{pv_prefix}:SomeTable", client_put_table_value), + ctxt.put(f"{pv_prefix}:SomeEnum", client_put_enum_value), + ) + + waveform_values, table_values, enum_values = [], [], [] + waveform_monitor = ctxt.monitor(f"{pv_prefix}:SomeWaveform", waveform_values.append) + table_monitor = ctxt.monitor(f"{pv_prefix}:SomeTable", table_values.append) + enum_monitor = ctxt.monitor( + f"{pv_prefix}:SomeEnum", + enum_values.append, + ) + serve = asyncio.ensure_future(fastcs.serve()) + wait_and_set_attrs = asyncio.ensure_future(_wait_and_set_attrs()) + wait_and_put_pvs = asyncio.ensure_future(_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 + ) + ) + except TimeoutError: + ... + finally: + waveform_monitor.close() + table_monitor.close() + enum_monitor.close() + serve.cancel() + wait_and_set_attrs.cancel() + wait_and_put_pvs.cancel() + + expected_waveform_gets = [ + initial_waveform_value, + server_set_waveform_value, + client_put_waveform_value, + ] + + for expected_waveform, actual_waveform in zip( + expected_waveform_gets, waveform_values, strict=True + ): + np.testing.assert_array_equal( + expected_waveform, actual_waveform.raw.value.reshape(10, 10) + ) + + expected_table_gets = [ + NTTable(columns=table_columns).wrap(initial_table_value), + NTTable(columns=table_columns).wrap(server_set_table_value), + client_put_table_value, + ] + for expected_table, actual_table in zip( + expected_table_gets, table_values, strict=True + ): + expected_table = expected_table.todict()["value"] + actual_table = actual_table.todict()["value"] + for expected_column, actual_column in zip( + expected_table.values(), actual_table.values(), strict=True + ): + if isinstance(expected_column, np.ndarray): + np.testing.assert_array_equal(expected_column, actual_column) + else: + assert expected_column == actual_column and actual_column is None + + expected_enum_gets = [ + initial_enum_value, + server_set_enum_value, + AnEnum.C, + ] + + 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 diff --git a/tests/transport/epics/p4p/test_p4p_system.py b/tests/transport/epics/p4p/test_p4p_system.py deleted file mode 100644 index c72687554..000000000 --- a/tests/transport/epics/p4p/test_p4p_system.py +++ /dev/null @@ -1,41 +0,0 @@ -from p4p import Value -from p4p.client.thread import Context - - -def test_ioc(p4p_subprocess: None): - ctxt = Context("pva") - - _parent_pvi = ctxt.get("P4P_TEST_DEVICE: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": "some controller"} - assert parent_pvi["value"] == { - "a": {"r": "P4P_TEST_DEVICE:A"}, - "b": {"rw": "P4P_TEST_DEVICE:B"}, - "child": { - "d": { - "v1": "P4P_TEST_DEVICE:Child1:PVI", - "v2": "P4P_TEST_DEVICE:Child2:PVI", - } - }, - "table": { - "rw": "P4P_TEST_DEVICE:Table", - }, - } - - child_pvi_pv = parent_pvi["value"]["child"]["d"]["v1"] - _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": "some sub controller"} - assert child_pvi["value"] == { - "c": {"w": "P4P_TEST_DEVICE:Child1:C"}, - "d": {"x": "P4P_TEST_DEVICE:Child1:D"}, - "e": {"r": "P4P_TEST_DEVICE:Child1:E"}, - "f": {"rw": "P4P_TEST_DEVICE:Child1:F"}, - "g": {"rw": "P4P_TEST_DEVICE:Child1:G"}, - "h": {"rw": "P4P_TEST_DEVICE:Child1:H"}, - "i": {"x": "P4P_TEST_DEVICE:Child1:I"}, - } diff --git a/tests/transport/epics/softioc/test_softioc_system.py b/tests/transport/epics/softioc/test_softioc_system.py index 4f53268ab..1d324dab9 100644 --- a/tests/transport/epics/softioc/test_softioc_system.py +++ b/tests/transport/epics/softioc/test_softioc_system.py @@ -1,19 +1,22 @@ +from multiprocessing import Queue + from p4p import Value from p4p.client.thread import Context -def test_ioc(softioc_subprocess: None): +def test_ioc(softioc_subprocess: tuple[str, Queue]): + pv_prefix, _ = softioc_subprocess ctxt = Context("pva") - _parent_pvi = ctxt.get("DEVICE:PVI") + _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": "DEVICE:A"}, - "b": {"r": "DEVICE:B_RBV", "w": "DEVICE:B"}, - "child": {"d": "DEVICE:Child:PVI"}, + "a": {"r": f"{pv_prefix}:A"}, + "b": {"r": f"{pv_prefix}:B_RBV", "w": f"{pv_prefix}:B"}, + "child": {"d": f"{pv_prefix}:Child:PVI"}, } child_pvi_pv = parent_pvi["value"]["child"]["d"] @@ -23,6 +26,6 @@ def test_ioc(softioc_subprocess: None): 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": "DEVICE:Child:C"}, - "d": {"x": "DEVICE:Child:D"}, + "c": {"w": f"{pv_prefix}:Child:C"}, + "d": {"x": f"{pv_prefix}:Child:D"}, } From 8cc9ffd3510ab736cca7cf6ec5f406bc8ebaa098 Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Fri, 14 Feb 2025 10:32:02 +0000 Subject: [PATCH 7/8] Simplified `PviTree` and made review changes. * General fixes. * Use `buildType` directly on `NTScalar`. This necessitates passing a wrap into the `SharedPV` instead of an `nt`. * Made `EpicsPVAOptions` and `EpicsCAOptions` (containing the same suboptions). * Renamed instances of `softioc` with `ca` and `p4p` with `pva` in the public API. --- src/fastcs/datatypes.py | 17 +- src/fastcs/launch.py | 38 ++-- src/fastcs/transport/__init__.py | 4 +- .../transport/epics/{p4p => ca}/__init__.py | 0 .../epics/{softioc => ca}/adapter.py | 12 +- .../transport/epics/{softioc => ca}/ioc.py | 4 +- src/fastcs/transport/epics/ca/options.py | 14 ++ .../transport/epics/{softioc => ca}/util.py | 0 src/fastcs/transport/epics/options.py | 15 +- src/fastcs/transport/epics/p4p/pvi_tree.py | 197 ------------------ .../epics/{softioc => pva}/__init__.py | 0 .../{p4p/handlers.py => pva/_pv_handlers.py} | 40 ++-- .../transport/epics/{p4p => pva}/adapter.py | 10 +- .../transport/epics/{p4p => pva}/ioc.py | 45 ++-- src/fastcs/transport/epics/pva/options.py | 14 ++ src/fastcs/transport/epics/pva/pvi_tree.py | 191 +++++++++++++++++ .../transport/epics/{p4p => pva}/types.py | 110 +++++----- tests/benchmarking/controller.py | 6 +- tests/data/schema.json | 34 +-- tests/example_p4p_ioc.py | 7 +- tests/example_softioc.py | 7 +- .../epics/{softioc => ca}/test_gui.py | 0 .../epics/{softioc => ca}/test_softioc.py | 40 ++-- .../{softioc => ca}/test_softioc_system.py | 0 .../epics/{softioc => ca}/test_util.py | 0 .../transport/epics/{p4p => pva}/test_p4p.py | 74 ++++--- 26 files changed, 474 insertions(+), 405 deletions(-) rename src/fastcs/transport/epics/{p4p => ca}/__init__.py (100%) rename src/fastcs/transport/epics/{softioc => ca}/adapter.py (77%) rename src/fastcs/transport/epics/{softioc => ca}/ioc.py (99%) create mode 100644 src/fastcs/transport/epics/ca/options.py rename src/fastcs/transport/epics/{softioc => ca}/util.py (100%) delete mode 100644 src/fastcs/transport/epics/p4p/pvi_tree.py rename src/fastcs/transport/epics/{softioc => pva}/__init__.py (100%) rename src/fastcs/transport/epics/{p4p/handlers.py => pva/_pv_handlers.py} (78%) rename src/fastcs/transport/epics/{p4p => pva}/adapter.py (77%) rename src/fastcs/transport/epics/{p4p => pva}/ioc.py (62%) create mode 100644 src/fastcs/transport/epics/pva/options.py create mode 100644 src/fastcs/transport/epics/pva/pvi_tree.py rename src/fastcs/transport/epics/{p4p => pva}/types.py (63%) rename tests/transport/epics/{softioc => ca}/test_gui.py (100%) rename tests/transport/epics/{softioc => ca}/test_softioc.py (92%) rename tests/transport/epics/{softioc => ca}/test_softioc_system.py (100%) rename tests/transport/epics/{softioc => ca}/test_util.py (100%) rename tests/transport/epics/{p4p => pva}/test_p4p.py (87%) diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py index 2172e4175..8b96b81a1 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 a3babd5f5..25b387e31 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 ] @@ -33,29 +34,26 @@ def __init__( transport_options: TransportOptions, ): self._loop = asyncio.get_event_loop() - self._loop.set_debug(True) self._backend = Backend(controller, self._loop) transport: TransportAdapter 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 5efce3f41..b9c0d097c 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 b8f31c988..090b8b234 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 bd276ced1..7bee56ea0 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 000000000..547a35108 --- /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 dc5ed4878..c42177c11 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 bf81d8fa8..000000000 --- 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 ed703fbfc..242e68a48 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 fafb6c864..c50ab0175 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 06ef44cae..70cad6a4c 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 000000000..e9ee9fb26 --- /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 000000000..0c3c01d44 --- /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 850375326..94f1c8048 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 4b874b5c9..cb1c895bb 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 4d5398ff4..d3dc5bc0f 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 a150b610f..9f1bdd8d3 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 1ff17d2b7..69cd3ad98 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 f4d9a8bee..fdaf877ae 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 31e12b4d4..10022adc4 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"] + ] + ) From d0df0b37d48c54515b93b4c7204a5d1d77ddff66 Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Mon, 3 Mar 2025 10:35:48 +0000 Subject: [PATCH 8/8] Changes from review. Commands now raise an error if initiated while already running. The command can spawn a task if it should run for a longer length of time, or needs to be ran multiple times concurrently. --- src/fastcs/transport/epics/ca/adapter.py | 4 +- src/fastcs/transport/epics/ca/ioc.py | 2 +- .../transport/epics/pva/_pv_handlers.py | 69 ++++++++------ src/fastcs/transport/epics/pva/ioc.py | 13 ++- src/fastcs/transport/epics/pva/pvi_tree.py | 74 +++++++-------- tests/transport/epics/ca/test_softioc.py | 6 +- tests/transport/epics/pva/test_p4p.py | 89 +++++++++++++++++++ 7 files changed, 179 insertions(+), 78 deletions(-) diff --git a/src/fastcs/transport/epics/ca/adapter.py b/src/fastcs/transport/epics/ca/adapter.py index 090b8b234..ec490bc7a 100644 --- a/src/fastcs/transport/epics/ca/adapter.py +++ b/src/fastcs/transport/epics/ca/adapter.py @@ -2,7 +2,7 @@ from fastcs.controller import Controller from fastcs.transport.adapter import TransportAdapter -from fastcs.transport.epics.ca.ioc import EpicsIOC +from fastcs.transport.epics.ca.ioc import EpicsCAIOC from fastcs.transport.epics.ca.options import EpicsCAOptions from fastcs.transport.epics.docs import EpicsDocs from fastcs.transport.epics.gui import EpicsGUI @@ -19,7 +19,7 @@ def __init__( self._loop = loop self._options = options or EpicsCAOptions() self._pv_prefix = self.options.ioc.pv_prefix - self._ioc = EpicsIOC( + self._ioc = EpicsCAIOC( self.options.ioc.pv_prefix, controller, self._options.ioc, diff --git a/src/fastcs/transport/epics/ca/ioc.py b/src/fastcs/transport/epics/ca/ioc.py index 7bee56ea0..8dc1d891a 100644 --- a/src/fastcs/transport/epics/ca/ioc.py +++ b/src/fastcs/transport/epics/ca/ioc.py @@ -22,7 +22,7 @@ EPICS_MAX_NAME_LENGTH = 60 -class EpicsIOC: +class EpicsCAIOC: def __init__( self, pv_prefix: str, diff --git a/src/fastcs/transport/epics/pva/_pv_handlers.py b/src/fastcs/transport/epics/pva/_pv_handlers.py index 242e68a48..141942e63 100644 --- a/src/fastcs/transport/epics/pva/_pv_handlers.py +++ b/src/fastcs/transport/epics/pva/_pv_handlers.py @@ -1,10 +1,10 @@ -import asyncio -import time from collections.abc import Callable import numpy as np from p4p import Value from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable +from p4p.nt.enum import ntenum +from p4p.nt.ndarray import ntndarray from p4p.server import ServerOperation from p4p.server.asyncio import SharedPV @@ -18,6 +18,7 @@ cast_to_p4p_value, make_p4p_type, p4p_alarm_states, + p4p_timestamp_now, ) @@ -33,10 +34,13 @@ async def put(self, pv: SharedPV, op: ServerOperation): [tuple(labelled_row.values()) for labelled_row in value], dtype=self._attr_w.datatype.structured_dtype, ) - elif hasattr(value, "raw"): - raw_value = value.raw.value - else: + elif isinstance(value, Value): raw_value = value.todict()["value"] + else: + # Unfortunately these types don't have a `todict`, + # while our `buildType` fields don't have a `.raw`. + assert isinstance(value, ntenum | ntndarray) + raw_value = value.raw.value # type:ignore cast_value = cast_from_p4p_value(self._attr_w, raw_value) @@ -50,45 +54,47 @@ async def put(self, pv: SharedPV, op: ServerOperation): class CommandPvHandler: def __init__(self, command: Callable): self._command = command - self._task_started_event = asyncio.Event() + self._task_in_progress = False - async def _run_command(self, pv: SharedPV): - self._task_started_event.set() - self._task_started_event.clear() + async def _run_command(self) -> dict: + self._task_in_progress = True - kwargs = {} try: await self._command() except Exception as e: - kwargs.update( - p4p_alarm_states(MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, str(e)) + alarm_states = p4p_alarm_states( + MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, str(e) ) else: - kwargs.update(p4p_alarm_states()) + alarm_states = p4p_alarm_states() - value = NTScalar("?").wrap({"value": False, **kwargs}) - timestamp = time.time() - pv.close() - pv.open(value, timestamp=timestamp) - pv.post(value, timestamp=timestamp) + self._task_in_progress = False + return alarm_states async def put(self, pv: SharedPV, op: ServerOperation): value = op.value() - raw_value = value.raw.value + raw_value = value["value"] if raw_value is True: - asyncio.create_task(self._run_command(pv)) - await self._task_started_event.wait() - - # Flip to true once command task starts - pv.post(value, timestamp=time.time()) - op.done() + if self._task_in_progress: + raise RuntimeError( + "Received request to run command but it is already in progress. " + "Maybe the command should spawn an asyncio task?" + ) + + # Flip to true once command task starts + pv.post({"value": True, **p4p_timestamp_now(), **p4p_alarm_states()}) + op.done() + alarm_states = await self._run_command() + pv.post({"value": False, **p4p_timestamp_now(), **alarm_states}) + else: + raise RuntimeError("Commands should only take the value `True`.") def make_shared_pv(attribute: Attribute) -> SharedPV: initial_value = ( attribute.get() - if isinstance(attribute, AttrRW | AttrR) + if isinstance(attribute, AttrR) else attribute.datatype.initial_value ) @@ -120,10 +126,17 @@ async def on_update(value): def make_command_pv(command: Callable) -> SharedPV: + type_ = NTScalar.buildType("?", display=True, control=True) + + initial = Value(type_, {"value": False, **p4p_alarm_states()}) + + def _wrap(value: dict): + return Value(type_, value) + shared_pv = SharedPV( - nt=NTScalar("?"), - initial=False, + initial=initial, handler=CommandPvHandler(command), + wrap=_wrap, ) return shared_pv diff --git a/src/fastcs/transport/epics/pva/ioc.py b/src/fastcs/transport/epics/pva/ioc.py index 70cad6a4c..5c26fbcf4 100644 --- a/src/fastcs/transport/epics/pva/ioc.py +++ b/src/fastcs/transport/epics/pva/ioc.py @@ -38,16 +38,14 @@ def get_pv_name(pv_prefix: str, *attribute_names: str) -> str: async def parse_attributes( root_pv_prefix: str, controller: Controller ) -> list[StaticProvider]: - providers = [] pvi_tree = PviTree(root_pv_prefix) + provider = StaticProvider(root_pv_prefix) for single_mapping in controller.get_controller_mappings(): path = single_mapping.controller.path pv_prefix = get_pv_name(root_pv_prefix, *path) - provider = StaticProvider(pv_prefix) - providers.append(provider) - pvi_tree.add_block( + pvi_tree.add_sub_device( pv_prefix, single_mapping.controller.description, ) @@ -56,7 +54,7 @@ async def parse_attributes( 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, _attribute_to_access(attribute)) + pvi_tree.add_signal(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) @@ -64,10 +62,9 @@ async def parse_attributes( MethodType(method.fn, single_mapping.controller) ) provider.add(pv_name, command_pv) - pvi_tree.add_field(pv_name, "x") + pvi_tree.add_signal(pv_name, "x") - providers.append(pvi_tree.make_provider()) - return providers + return [provider, pvi_tree.make_provider()] class P4PIOC: diff --git a/src/fastcs/transport/epics/pva/pvi_tree.py b/src/fastcs/transport/epics/pva/pvi_tree.py index 0c3c01d44..0ca0ce6ca 100644 --- a/src/fastcs/transport/epics/pva/pvi_tree.py +++ b/src/fastcs/transport/epics/pva/pvi_tree.py @@ -16,7 +16,7 @@ @dataclass -class _PviFieldInfo: +class _PviSignalInfo: pv: str access: AccessModeType @@ -34,55 +34,57 @@ def _pv_to_pvi_name(pv: str) -> tuple[str, int | None]: return _pascal_to_snake(string_without_number), number -class PviBlock(dict[str, "PviBlock"]): +class PviDevice(dict[str, "PviDevice"]): pv_prefix: str description: str | None - block_field_info: _PviFieldInfo | None + device_signal_info: _PviSignalInfo | None def __init__( self, pv_prefix: str, description: str | None = None, - block_field_info: _PviFieldInfo | None = None, + device_signal_info: _PviSignalInfo | None = None, ): self.pv_prefix = pv_prefix self.description = description - self.block_field_info = block_field_info + self.device_signal_info = device_signal_info - def __missing__(self, key: str) -> "PviBlock": - new_block = PviBlock(pv_prefix=f"{self.pv_prefix}:{key}") - self[key] = new_block + def __missing__(self, key: str) -> "PviDevice": + new_device = PviDevice(pv_prefix=f"{self.pv_prefix}:{key}") + self[key] = new_device return self[key] - def get_recursively(self, *args: str) -> "PviBlock": + def get_recursively(self, *args: str) -> "PviDevice": 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] = {} + def _get_signal_infos(self) -> dict[str, _PviSignalInfo]: + device_signal_infos: dict[str, _PviSignalInfo] = {} - 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" + for sub_device_name, sub_device in self.items(): + if sub_device: + device_signal_infos[f"{sub_device_name}:PVI"] = _PviSignalInfo( + pv=f"{sub_device.pv_prefix}:PVI", access="d" ) - if sub_block.block_field_info: - block_field_infos[sub_block_name] = sub_block.block_field_info + if sub_device.device_signal_info: + device_signal_infos[sub_device_name] = sub_device.device_signal_info - return block_field_infos + return device_signal_infos def _make_p4p_raw_value(self) -> dict: p4p_raw_value = defaultdict(dict) - for pv_leaf, field_info in self._get_field_infos().items(): + for pv_leaf, signal_info in self._get_signal_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 + if signal_info.access not in p4p_raw_value[pvi_name]: + p4p_raw_value[pvi_name][signal_info.access] = {} + p4p_raw_value[pvi_name][signal_info.access][f"v{number}"] = ( + signal_info.pv + ) else: - p4p_raw_value[pvi_name][field_info.access] = field_info.pv + p4p_raw_value[pvi_name][signal_info.access] = signal_info.pv return p4p_raw_value @@ -150,9 +152,9 @@ def make_provider( SharedPV(initial=self.make_p4p_value()), ) - for sub_block in self.values(): - if sub_block: - sub_block.make_provider(provider=provider) + for sub_device in self.values(): + if sub_device: + sub_device.make_provider(provider=provider) return provider @@ -160,32 +162,32 @@ def make_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) + self._pvi_tree_root: PviDevice = PviDevice(pv_prefix) - def add_block( + def add_sub_device( self, - block_pv: str, + device_pv: str, description: str | None, ): - if ":" not in block_pv: - assert block_pv == self._pvi_tree_root.pv_prefix + if ":" not in device_pv: + assert device_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 + *device_pv.split(":")[1:] # To remove the prefix ).description = description - def add_field( + def add_signal( self, attribute_pv: str, access: AccessModeType, ): - leaf_block = self._pvi_tree_root.get_recursively(*attribute_pv.split(":")[1:]) + leaf_device = self._pvi_tree_root.get_recursively(*attribute_pv.split(":")[1:]) - if leaf_block.block_field_info is not None: + if leaf_device.device_signal_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) + leaf_device.device_signal_info = _PviSignalInfo(pv=attribute_pv, access=access) def make_provider(self) -> StaticProvider: return self._pvi_tree_root.make_provider() diff --git a/tests/transport/epics/ca/test_softioc.py b/tests/transport/epics/ca/test_softioc.py index fdaf877ae..59f4acca4 100644 --- a/tests/transport/epics/ca/test_softioc.py +++ b/tests/transport/epics/ca/test_softioc.py @@ -19,7 +19,7 @@ from fastcs.exceptions import FastCSException from fastcs.transport.epics.ca.ioc import ( EPICS_MAX_NAME_LENGTH, - EpicsIOC, + EpicsCAIOC, _add_attr_pvi_info, _add_pvi_info, _add_sub_controller_pvi_info, @@ -204,7 +204,7 @@ def test_ioc(mocker: MockerFixture, controller: Controller): "fastcs.transport.epics.ca.ioc._add_sub_controller_pvi_info" ) - EpicsIOC(DEVICE, controller) + EpicsCAIOC(DEVICE, controller) # Check records are created builder.boolIn.assert_called_once_with( @@ -397,7 +397,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.attr_rw_short_name.enabled assert getattr(long_name_controller, long_attr_name).enabled - EpicsIOC(DEVICE, long_name_controller) + EpicsCAIOC(DEVICE, long_name_controller) assert long_name_controller.attr_rw_short_name.enabled assert not getattr(long_name_controller, long_attr_name).enabled diff --git a/tests/transport/epics/pva/test_p4p.py b/tests/transport/epics/pva/test_p4p.py index 10022adc4..9c7e35b44 100644 --- a/tests/transport/epics/pva/test_p4p.py +++ b/tests/transport/epics/pva/test_p4p.py @@ -1,5 +1,6 @@ import asyncio import enum +from datetime import datetime from multiprocessing import Queue from unittest.mock import ANY from uuid import uuid4 @@ -18,6 +19,7 @@ from fastcs.launch import FastCS from fastcs.transport.epics.options import EpicsIOCOptions from fastcs.transport.epics.pva.options import EpicsPVAOptions +from fastcs.wrappers import command @pytest.mark.asyncio @@ -538,3 +540,90 @@ async def _wait_and_put_pvs(): actual_enum.todict()["value"]["index"] ] ) + + +def test_command_method_put_twice(caplog): + class SomeController(Controller): + command_runs_for_a_while_times = [] + command_spawns_a_task_times = [] + command_task_times = [] + + @command() + async def command_runs_for_a_while(self): + start_time = datetime.now() + await asyncio.sleep(0.1) + self.command_runs_for_a_while_times.append((start_time, datetime.now())) + + @command() + async def command_spawns_a_task(self): + start_time = datetime.now() + + async def some_task(): + task_start_time = datetime.now() + await asyncio.sleep(0.1) + self.command_task_times.append((task_start_time, datetime.now())) + + self.command_spawns_a_task_times.append((start_time, datetime.now())) + + asyncio.create_task(some_task()) + + controller = SomeController() + pv_prefix = str(uuid4()) + fastcs = make_fastcs(pv_prefix, controller) + expected_error_string = ( + "RuntimeError: Received request to run command but it is " + "already in progress. Maybe the command should spawn an asyncio task?" + ) + + async def put_pvs(): + await asyncio.sleep(0.1) + ctxt = Context("pva") + await ctxt.put(f"{pv_prefix}:CommandSpawnsATask", True) + await ctxt.put(f"{pv_prefix}:CommandSpawnsATask", True) + await ctxt.put(f"{pv_prefix}:CommandRunsForAWhile", True) + assert expected_error_string not in caplog.text + await ctxt.put(f"{pv_prefix}:CommandRunsForAWhile", True) + assert expected_error_string in caplog.text + + serve = asyncio.ensure_future(fastcs.serve()) + try: + asyncio.get_event_loop().run_until_complete( + asyncio.wait_for( + asyncio.gather(serve, put_pvs()), + timeout=3, + ) + ) + except TimeoutError: + ... + serve.cancel() + + assert ( + len(controller.command_task_times) + == len(controller.command_spawns_a_task_times) + == 2 + ) + for (task_start_time, task_end_time), ( + task_spawn_start_time, + task_spawn_end_time, + ) in zip( + controller.command_task_times, + controller.command_spawns_a_task_times, + strict=True, + ): + assert ( + pytest.approx( + (task_spawn_end_time - task_spawn_start_time).total_seconds(), abs=0.05 + ) + == 0 + ) + assert ( + pytest.approx((task_end_time - task_start_time).total_seconds(), abs=0.05) + == 0.1 + ) + + assert len(controller.command_runs_for_a_while_times) == 1 + coro_start_time, coro_end_time = controller.command_runs_for_a_while_times[0] + assert ( + pytest.approx((coro_end_time - coro_start_time).total_seconds(), abs=0.05) + == 0.1 + )