diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 40fbc210..8a71b49b 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections import Counter -from collections.abc import Sequence +from collections.abc import Iterator, Mapping, MutableMapping, Sequence from copy import deepcopy from typing import get_type_hints @@ -17,6 +17,7 @@ class BaseController(Tracer): #: Attributes passed from the device at runtime. attributes: dict[str, Attribute] + root_attribute: Attribute | None = None description: str | None = None @@ -36,7 +37,7 @@ def __init__( if not hasattr(self, "attributes"): self.attributes = {} self._path: list[str] = path or [] - self.__sub_controller_tree: dict[str, Controller] = {} + self.__sub_controller_tree: dict[str, BaseController] = {} self._bind_attrs() @@ -144,7 +145,7 @@ def add_attribute(self, name, attribute: Attribute): self.attributes[name] = attribute super().__setattr__(name, attribute) - def add_sub_controller(self, name: str, sub_controller: Controller): + def add_sub_controller(self, name: str, sub_controller: BaseController): if name in self.__sub_controller_tree.keys(): raise ValueError( f"Cannot add sub controller {sub_controller}. " @@ -166,7 +167,7 @@ def add_sub_controller(self, name: str, sub_controller: Controller): self.attributes[name] = sub_controller.root_attribute @property - def sub_controllers(self) -> dict[str, Controller]: + def sub_controllers(self) -> dict[str, BaseController]: return self.__sub_controller_tree def __repr__(self): @@ -194,8 +195,6 @@ class Controller(BaseController): such as generating a UI or creating parameters for a control system. """ - root_attribute: Attribute | None = None - def __init__( self, description: str | None = None, @@ -203,8 +202,66 @@ def __init__( ) -> None: super().__init__(description=description, ios=ios) + def add_sub_controller(self, name: str, sub_controller: BaseController): + if name.isdigit(): + raise ValueError( + f"Cannot add sub controller {name}. " + "Numeric-only names are not allowed; use ControllerVector instead" + ) + return super().add_sub_controller(name, sub_controller) + async def connect(self) -> None: pass async def disconnect(self) -> None: pass + + +class ControllerVector(MutableMapping[int, Controller], BaseController): + """A controller with a collection of identical sub controllers distinguished + by a numeric value""" + + def __init__( + self, + children: Mapping[int, Controller], + description: str | None = None, + ios: Sequence[AttributeIO[T, AttributeIORefT]] | None = None, + ) -> None: + super().__init__(description=description, ios=ios) + self._children: dict[int, Controller] = {} + for index, child in children.items(): + self[index] = child + + def add_sub_controller(self, name: str, sub_controller: BaseController): + raise NotImplementedError( + "Cannot add named sub controller to ControllerVector. " + "Use __setitem__ instead, for indexed sub controllers. " + "E.g., vector[1] = Controller()" + ) + + def __getitem__(self, key: int) -> Controller: + try: + return self._children[key] + except KeyError as exception: + raise KeyError( + f"ControllerVector does not have Controller with key {key}" + ) from exception + + def __setitem__(self, key: int, value: Controller) -> None: + if not isinstance(key, int): + msg = f"Expected int, got {key}" + raise TypeError(msg) + if not isinstance(value, Controller): + msg = f"Expected Controller, got {value}" + raise TypeError(msg) + self._children[key] = value + super().add_sub_controller(str(key), value) + + def __delitem__(self, key: int) -> None: + raise NotImplementedError("Cannot delete sub controller from ControllerVector.") + + def __iter__(self) -> Iterator[int]: + yield from self._children + + def __len__(self) -> int: + return len(self._children) diff --git a/src/fastcs/controller_api.py b/src/fastcs/controller_api.py index 88b4c38f..43110510 100644 --- a/src/fastcs/controller_api.py +++ b/src/fastcs/controller_api.py @@ -38,9 +38,12 @@ def walk_api(self) -> Iterator["ControllerAPI"]: yield from api.walk_api() def __repr__(self): - return f"""\ -ControllerAPI(path={self.path}, sub_apis=[{", ".join(self.sub_apis.keys())}])\ -""" + return ( + f"ControllerAPI(" + f"path={self.path}, " + f"sub_apis=[{', '.join(self.sub_apis.keys())}]" + f")" + ) def get_scan_and_initial_coros( self, diff --git a/src/fastcs/transport/epics/ca/ioc.py b/src/fastcs/transport/epics/ca/ioc.py index 33ce409c..cad93fe4 100644 --- a/src/fastcs/transport/epics/ca/ioc.py +++ b/src/fastcs/transport/epics/ca/ioc.py @@ -113,9 +113,14 @@ def _add_sub_controller_pvi_info(pv_prefix: str, parent: ControllerAPI): for child in parent.sub_apis.values(): child_pvi = f"{controller_pv_prefix(pv_prefix, child)}:PVI" - child_name = child.path[-1].lower() + child_name = ( + f"__{child.path[-1]}" # Sub-Controller of ControllerVector + if child.path[-1].isdigit() + else child.path[-1] + ) + + _add_pvi_info(child_pvi, parent_pvi, child_name.lower()) - _add_pvi_info(child_pvi, parent_pvi, child_name) _add_sub_controller_pvi_info(pv_prefix, child) diff --git a/src/fastcs/transport/epics/ca/util.py b/src/fastcs/transport/epics/ca/util.py index 66af0cd2..312252ee 100644 --- a/src/fastcs/transport/epics/ca/util.py +++ b/src/fastcs/transport/epics/ca/util.py @@ -92,6 +92,9 @@ def _verify_in_datatype(_, value): return value in datatype.names arguments["validate"] = _verify_in_datatype + case Bool(): + arguments["ZNAM"] = "False" + arguments["ONAM"] = "True" return arguments diff --git a/src/fastcs/transport/epics/gui.py b/src/fastcs/transport/epics/gui.py index f2a0be9f..a87893f3 100644 --- a/src/fastcs/transport/epics/gui.py +++ b/src/fastcs/transport/epics/gui.py @@ -160,6 +160,8 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree: components: Tree = [] for name, api in controller_api.sub_apis.items(): + if name.isdigit(): + name = f"{controller_api.path[-1]}{name}" components.append( Group( name=snake_to_pascal(name), diff --git a/src/fastcs/transport/epics/pva/ioc.py b/src/fastcs/transport/epics/pva/ioc.py index be20f27e..f865845b 100644 --- a/src/fastcs/transport/epics/pva/ioc.py +++ b/src/fastcs/transport/epics/pva/ioc.py @@ -2,7 +2,7 @@ from p4p.server import Server, StaticProvider -from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controller_api import ControllerAPI from fastcs.transport.epics.util import controller_pv_prefix from fastcs.util import snake_to_pascal @@ -12,32 +12,23 @@ make_shared_read_pv, make_shared_write_pv, ) -from .pvi_tree import AccessModeType, PviTree - - -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)}") +from .pvi import add_pvi_info async def parse_attributes( root_pv_prefix: str, root_controller_api: ControllerAPI -) -> list[StaticProvider]: +) -> StaticProvider: """Parses `Attribute` s into p4p signals in handlers.""" - pvi_tree = PviTree(root_pv_prefix) provider = StaticProvider(root_pv_prefix) for controller_api in root_controller_api.walk_api(): pv_prefix = controller_pv_prefix(root_pv_prefix, controller_api) - - pvi_tree.add_sub_device(pv_prefix, controller_api.description) + provider = add_pvi_info( + provider=provider, + pv_prefix=pv_prefix, + controller_api=controller_api, + description=controller_api.description, + ) for attr_name, attribute in controller_api.attributes.items(): full_pv_name = f"{pv_prefix}:{snake_to_pascal(attr_name)}" @@ -47,23 +38,19 @@ async def parse_attributes( attribute_pv_rbv = make_shared_read_pv(attribute) provider.add(f"{full_pv_name}", attribute_pv) provider.add(f"{full_pv_name}_RBV", attribute_pv_rbv) - pvi_tree.add_signal(f"{full_pv_name}", "rw") case AttrR(): attribute_pv = make_shared_read_pv(attribute) provider.add(f"{full_pv_name}", attribute_pv) - pvi_tree.add_signal(f"{full_pv_name}", "r") case AttrW(): attribute_pv = make_shared_write_pv(attribute) provider.add(f"{full_pv_name}", attribute_pv) - pvi_tree.add_signal(f"{full_pv_name}", "w") for attr_name, method in controller_api.command_methods.items(): full_pv_name = f"{pv_prefix}:{snake_to_pascal(attr_name)}" command_pv = make_command_pv(method.fn) provider.add(f"{full_pv_name}", command_pv) - pvi_tree.add_signal(f"{full_pv_name}", "x") - return [provider, pvi_tree.make_provider()] + return provider class P4PIOC: @@ -74,8 +61,8 @@ def __init__(self, pv_prefix: str, controller_api: ControllerAPI): self.controller_api = controller_api async def run(self): - providers = await parse_attributes(self.pv_prefix, self.controller_api) + provider = await parse_attributes(self.pv_prefix, self.controller_api) endless_event = asyncio.Event() - with Server(providers): + with Server([provider]): await endless_event.wait() diff --git a/src/fastcs/transport/epics/pva/pvi.py b/src/fastcs/transport/epics/pva/pvi.py new file mode 100644 index 00000000..a3a7d448 --- /dev/null +++ b/src/fastcs/transport/epics/pva/pvi.py @@ -0,0 +1,119 @@ +from collections import defaultdict +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.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.controller_api import ControllerAPI +from fastcs.util import snake_to_pascal + +from .types import p4p_alarm_states, p4p_timestamp_now + +AccessModeType = Literal["r", "w", "rw", "d", "x"] + + +# TODO: This should be removed after https://github.com/DiamondLightSource/FastCS/issues/260 +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 add_pvi_info( + provider: StaticProvider, + pv_prefix: str, + controller_api: ControllerAPI, + description: str | None = None, +) -> StaticProvider: + """Add PVI information to given provider.""" + provider.add( + f"{pv_prefix}:PVI", + SharedPV(initial=_make_p4p_value(pv_prefix, controller_api, description)), + ) + return provider + + +def _make_p4p_value( + pv_prefix: str, controller_api: ControllerAPI, description: str | None +) -> Value: + display = ( + {"display": {"description": description}} if description is not None else {} + ) # Defined here so the value can be (none) + + raw_value = _make_p4p_raw_value(pv_prefix, controller_api) + p4p_type = _make_type_for_raw_value(raw_value) + + try: + return Value( + p4p_type, + { + **p4p_alarm_states(), + **p4p_timestamp_now(), + **display, + "value": raw_value, + }, + ) + except KeyError as e: + raise ValueError(f"Failed to create p4p Value from {raw_value}") from e + + +def _make_p4p_raw_value(pv_prefix: str, controller_api: ControllerAPI) -> dict: + p4p_raw_value = defaultdict(dict) + # Sub-controller api returned if current item is a Controller + for pv_leaf, sub_controller_api in controller_api.sub_apis.items(): + # Add Controller entry + pv = f"{pv_prefix}:{snake_to_pascal(pv_leaf)}:PVI" + if sub_controller_api.path[-1].isdigit(): + # Sub-device of a ControllerVector + p4p_raw_value[f"__{int(pv_leaf)}"]["d"] = pv + else: + p4p_raw_value[pv_leaf]["d"] = pv + for pv_leaf, attribute in controller_api.attributes.items(): + # Add attribute entry + pv = f"{pv_prefix}:{snake_to_pascal(pv_leaf)}" + p4p_raw_value[pv_leaf][_attribute_to_access(attribute)] = pv + for pv_leaf, _ in controller_api.command_methods.items(): + pv = f"{pv_prefix}:{snake_to_pascal(pv_leaf)}" + p4p_raw_value[pv_leaf]["x"] = pv + + return p4p_raw_value + + +def _make_type_for_raw_value(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)), + ] + ) diff --git a/src/fastcs/transport/epics/pva/pvi_tree.py b/src/fastcs/transport/epics/pva/pvi_tree.py deleted file mode 100644 index 22480478..00000000 --- a/src/fastcs/transport/epics/pva/pvi_tree.py +++ /dev/null @@ -1,207 +0,0 @@ -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 _PviSignalInfo: - """For storing a pv and it's access in pvi parsing.""" - - 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 PviDevice(dict[str, "PviDevice"]): - """For creating a pvi structure in pva.""" - - pv_prefix: str - description: str | None - device_signal_info: _PviSignalInfo | None - - def __init__( - self, - pv_prefix: str, - description: str | None = None, - device_signal_info: _PviSignalInfo | None = None, - ): - self.pv_prefix = pv_prefix - self.description = description - self.device_signal_info = device_signal_info - - 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) -> "PviDevice": - d = self - for arg in args: - d = d[arg] - return d - - def _get_signal_infos(self) -> dict[str, _PviSignalInfo]: - device_signal_infos: dict[str, _PviSignalInfo] = {} - - 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_device.device_signal_info: - device_signal_infos[sub_device_name] = sub_device.device_signal_info - - return device_signal_infos - - def _make_p4p_raw_value(self) -> dict: - p4p_raw_value = defaultdict(dict) - for pv_leaf, signal_info in self._get_signal_infos().items(): - stripped_leaf = pv_leaf.rstrip(":PVI") - is_controller = stripped_leaf != pv_leaf - pvi_name, number = _pv_to_pvi_name(stripped_leaf or pv_leaf) - if is_controller and number is not None: - 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 - ) - elif is_controller: - p4p_raw_value[pvi_name][signal_info.access] = signal_info.pv - else: - attr_pvi_name = f"{pvi_name}{'' if number is None else number}" - p4p_raw_value[attr_pvi_name][signal_info.access] = signal_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) - - try: - return Value( - p4p_type, - { - **p4p_alarm_states(), - **p4p_timestamp_now(), - **display, - "value": raw_value, - }, - ) - except KeyError as e: - raise ValueError(f"Failed to create p4p Value from {raw_value}") from e - - 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_device in self.values(): - if sub_device: - sub_device.make_provider(provider=provider) - - return provider - - -# TODO: This can be dramatically cleaned up after https://github.com/DiamondLightSource/FastCS/issues/122 -class PviTree: - """For storing pvi structures.""" - - def __init__(self, pv_prefix: str): - self._pvi_tree_root: PviDevice = PviDevice(pv_prefix) - - def add_sub_device( - self, - device_pv: str, - description: str | None, - ): - 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( - *device_pv.split(":")[1:] # To remove the prefix - ).description = description - - def add_signal( - self, - attribute_pv: str, - access: AccessModeType, - ): - leaf_device = self._pvi_tree_root.get_recursively(*attribute_pv.split(":")[1:]) - - if leaf_device.device_signal_info is not None: - raise ValueError(f"Tried to add the field '{attribute_pv}' twice.") - - 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/example_p4p_ioc.py b/tests/example_p4p_ioc.py index 6196a30c..878e955f 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -1,11 +1,14 @@ import asyncio import enum +from dataclasses import dataclass import numpy as np +from fastcs.attribute_io import AttributeIO +from fastcs.attribute_io_ref import AttributeIORef from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller import Controller -from fastcs.datatypes import Bool, Enum, Float, Int, Table, Waveform +from fastcs.controller import Controller, ControllerVector +from fastcs.datatypes import Bool, Enum, Float, Int, T, Table, Waveform from fastcs.launch import FastCS from fastcs.transport.epics.options import ( EpicsIOCOptions, @@ -14,6 +17,17 @@ from fastcs.wrappers import command, scan +@dataclass +class SimpleAttributeIORef(AttributeIORef): + pass + + +class SimpleAttributeIO(AttributeIO[T, SimpleAttributeIORef]): + async def send(self, attr: AttrW[T, SimpleAttributeIORef], value): + if isinstance(attr, AttrRW): + await attr.update(value) + + class FEnum(enum.Enum): A = 0 B = 1 @@ -24,17 +38,26 @@ class FEnum(enum.Enum): class ParentController(Controller): description = "some controller" - a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) - b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5)) + a: AttrRW = AttrRW( + Int(max=400_000, max_alarm=40_000), io_ref=SimpleAttributeIORef() + ) + b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5), io_ref=SimpleAttributeIORef()) table: AttrRW = AttrRW( - Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]) + Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]), + io_ref=SimpleAttributeIORef(), ) + def __init__(self, description=None, ios=None): + super().__init__(description, ios) + class ChildController(Controller): fail_on_next_e = True - c: AttrW = AttrW(Int()) + c: AttrW = AttrW(Int(), io_ref=SimpleAttributeIORef()) + + def __init__(self, description=None, ios=None): + super().__init__(description, ios) @command() async def d(self): @@ -43,7 +66,7 @@ async def d(self): print("D: FINISHED") await self.j.update(self.j.get() + 1) - e: AttrR = AttrR(Bool()) + e: AttrR = AttrR(Bool(), io_ref=SimpleAttributeIORef()) @scan(1) async def flip_flop(self): @@ -69,15 +92,32 @@ async def i(self): def run(pv_prefix="P4P_TEST_DEVICE"): - controller = ParentController() - controller.a.enable_tracing() - controller.child1 = ChildController(description="some sub controller") - controller.child2 = ChildController(description="another sub controller") - - fastcs = FastCS( - controller, [EpicsPVATransport(pva_ioc=EpicsIOCOptions(pv_prefix=pv_prefix))] + simple_attribute_io = SimpleAttributeIO() + p4p_options = EpicsPVATransport(pva_ioc=EpicsIOCOptions(pv_prefix=pv_prefix)) + controller = ParentController(ios=[simple_attribute_io]) + + class ChildVector(ControllerVector): + vector_attribute: AttrR = AttrR(Int()) + + def __init__(self, children, description=None): + super().__init__(children, description) + + sub_controller = ChildVector( + { + 1: ChildController( + description="some sub controller", ios=[simple_attribute_io] + ), + 2: ChildController( + description="another sub controller", ios=[simple_attribute_io] + ), + }, + description="some child vector", ) - fastcs.run(interactive=False) + + controller.add_sub_controller("child", sub_controller) + + fastcs = FastCS(controller, [p4p_options]) + fastcs.run() if __name__ == "__main__": diff --git a/tests/example_softioc.py b/tests/example_softioc.py index d5dea6ee..ad40e5c6 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -1,8 +1,10 @@ +from pathlib import Path + from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller import Controller +from fastcs.controller import Controller, ControllerVector from fastcs.datatypes import Int from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport +from fastcs.transport.epics.ca.transport import EpicsCATransport, EpicsGUIOptions from fastcs.transport.epics.options import EpicsIOCOptions from fastcs.wrappers import command @@ -22,11 +24,20 @@ async def d(self): def run(pv_prefix="SOFTIOC_TEST_DEVICE"): controller = ParentController() - controller.child = ChildController() + vector = ControllerVector({i: ChildController() for i in range(2)}) + controller.add_sub_controller("ChildVector", vector) + gui_options = EpicsGUIOptions( + output_path=Path(".") / "demo.bob", title="Demo Vector" + ) fastcs = FastCS( - controller, [EpicsCATransport(ca_ioc=EpicsIOCOptions(pv_prefix=pv_prefix))] + controller, + [ + EpicsCATransport( + ca_ioc=EpicsIOCOptions(pv_prefix=pv_prefix), gui=gui_options + ) + ], ) - fastcs.run(interactive=False) + fastcs.run(interactive=True) if __name__ == "__main__": diff --git a/tests/test_controller.py b/tests/test_controller.py index 0bb0df26..4828c8ff 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,7 +1,7 @@ import pytest from fastcs.attributes import AttrR -from fastcs.controller import Controller +from fastcs.controller import Controller, ControllerVector from fastcs.datatypes import Float, Int @@ -101,3 +101,46 @@ def __init__(self): ValueError, match=r"Cannot add attribute .* existing sub controller" ): controller.sub_controller = AttrR(Int()) # pyright: ignore[reportAttributeAccessIssue] + + +def test_controller_raises_error_if_passed_numeric_sub_controller_name(): + sub_controller = SomeSubController() + controller = SomeController(sub_controller) + + with pytest.raises(ValueError, match="Numeric-only names are not allowed"): + controller.add_sub_controller("30", sub_controller) + + +def test_controller_vector_raises_error_if_add_sub_controller_called(): + controller_vector = ControllerVector({i: SomeSubController() for i in range(2)}) + + with pytest.raises(NotImplementedError, match="Use __setitem__ instead"): + controller_vector.add_sub_controller("subcontroller", SomeSubController()) + + +def test_controller_vector_indexing(): + controller = SomeSubController() + another_controller = SomeSubController() + controller_vector = ControllerVector({1: another_controller}) + controller_vector[10] = controller + assert controller_vector.sub_controllers["10"] == controller + assert controller_vector[1] == another_controller + assert len(controller_vector) == 2 + + with pytest.raises(KeyError): + _ = controller_vector[2] + + +def test_controller_vector_delitem_raises_exception(): + controller = SomeSubController() + controller_vector = ControllerVector({1: controller}) + with pytest.raises(NotImplementedError, match="Cannot delete"): + del controller_vector[1] + + +def test_controller_vector_iter(): + sub_controllers = {1: SomeSubController(), 2: SomeSubController()} + controller_vector = ControllerVector(sub_controllers) + + for index, child in controller_vector.items(): + assert sub_controllers[index] == child diff --git a/tests/transport/epics/ca/test_softioc_system.py b/tests/transport/epics/ca/test_softioc_system.py index 1d324dab..bda71d54 100644 --- a/tests/transport/epics/ca/test_softioc_system.py +++ b/tests/transport/epics/ca/test_softioc_system.py @@ -16,16 +16,31 @@ def test_ioc(softioc_subprocess: tuple[str, Queue]): assert parent_pvi["value"] == { "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"}, + "childvector": {"d": f"{pv_prefix}:ChildVector:PVI"}, } - child_pvi_pv = parent_pvi["value"]["child"]["d"] + child_vector_pvi_pv = parent_pvi["value"]["childvector"]["d"] + _child_vector_pvi = ctxt.get(child_vector_pvi_pv) + assert isinstance(_child_vector_pvi, Value) + _child_vector_pvi = _child_vector_pvi.todict() + assert all( + f in _child_vector_pvi for f in ("alarm", "display", "timeStamp", "value") + ) + assert _child_vector_pvi["display"] == { + "description": "The records in this controller" + } + assert _child_vector_pvi["value"] == { + "__0": {"d": f"{pv_prefix}:ChildVector:0:PVI"}, + "__1": {"d": f"{pv_prefix}:ChildVector:1:PVI"}, + } + + child_pvi_pv = _child_vector_pvi["value"]["__0"]["d"] _child_pvi = ctxt.get(child_pvi_pv) assert isinstance(_child_pvi, Value) child_pvi = _child_pvi.todict() assert all(f in child_pvi for f in ("alarm", "display", "timeStamp", "value")) assert child_pvi["display"] == {"description": "The records in this controller"} assert child_pvi["value"] == { - "c": {"w": f"{pv_prefix}:Child:C"}, - "d": {"x": f"{pv_prefix}:Child:D"}, + "c": {"w": f"{pv_prefix}:ChildVector:0:C"}, + "d": {"x": f"{pv_prefix}:ChildVector:0:D"}, } diff --git a/tests/transport/epics/pva/test_p4p.py b/tests/transport/epics/pva/test_p4p.py index 1cb5f9f9..d26c6922 100644 --- a/tests/transport/epics/pva/test_p4p.py +++ b/tests/transport/epics/pva/test_p4p.py @@ -14,7 +14,7 @@ from p4p.nt import NTTable from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller import Controller +from fastcs.controller import Controller, ControllerVector from fastcs.datatypes import Bool, Enum, Float, Int, String, Table, Waveform from fastcs.launch import FastCS from fastcs.transport.epics.options import EpicsIOCOptions @@ -35,32 +35,41 @@ async def test_ioc(p4p_subprocess: tuple[str, Queue]): 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", - } - }, + "child": {"d": f"{pv_prefix}:Child:PVI"}, "table": { "rw": f"{pv_prefix}:Table", }, } - child_pvi_pv = parent_pvi["value"]["child"]["d"]["v1"] + child_vector_pvi_pv = parent_pvi["value"]["child"]["d"] + _child_vector_pvi = await ctxt.get(child_vector_pvi_pv) + assert isinstance(_child_vector_pvi, Value) + _child_vector_pvi = _child_vector_pvi.todict() + assert all( + f in _child_vector_pvi for f in ("alarm", "display", "timeStamp", "value") + ) + assert _child_vector_pvi["display"] == {"description": "some child vector"} + assert _child_vector_pvi["value"] == { + "vector_attribute": {"r": f"{pv_prefix}:Child:VectorAttribute"}, + "__1": {"d": f"{pv_prefix}:Child:1:PVI"}, + "__2": {"d": f"{pv_prefix}:Child:2:PVI"}, + } + + child_pvi_pv = _child_vector_pvi["value"]["__1"]["d"] _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"}, - "j": {"r": f"{pv_prefix}:Child1:J"}, + "c": {"w": f"{pv_prefix}:Child:1:C"}, + "d": {"x": f"{pv_prefix}:Child:1:D"}, + "e": {"r": f"{pv_prefix}:Child:1:E"}, + "f": {"rw": f"{pv_prefix}:Child:1:F"}, + "g": {"rw": f"{pv_prefix}:Child:1:G"}, + "h": {"rw": f"{pv_prefix}:Child:1:H"}, + "i": {"x": f"{pv_prefix}:Child:1:I"}, + "j": {"r": f"{pv_prefix}:Child:1:J"}, } @@ -74,7 +83,7 @@ async def test_scan_method(p4p_subprocess: tuple[str, Queue]): # time for the p4p transport to update, broadcast, get. latency = 1e8 - e_monitor = ctxt.monitor(f"{pv_prefix}:Child1:E", e_values.put) + e_monitor = ctxt.monitor(f"{pv_prefix}:Child:1:E", e_values.put) try: # Throw away the value on the ioc setup so we can compare timestamps _ = await e_values.get() @@ -111,14 +120,14 @@ async def test_command_method(p4p_subprocess: tuple[str, Queue]): j_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) - j_monitor = ctxt.monitor(f"{pv_prefix}:Child1:J", j_values.put) + d_monitor = ctxt.monitor(f"{pv_prefix}:Child:1:D", d_values.put) + i_monitor = ctxt.monitor(f"{pv_prefix}:Child:1:I", i_values.put) + j_monitor = ctxt.monitor(f"{pv_prefix}:Child:1:J", j_values.put) try: j_initial_value = await j_values.get() assert (await d_values.get()).raw.value is False - await ctxt.put(f"{pv_prefix}:Child1:D", True) + await ctxt.put(f"{pv_prefix}:Child:1:D", True) assert (await d_values.get()).raw.value is True # D process hangs for 0.1s, so we wait slightly longer await asyncio.sleep(0.2) @@ -132,7 +141,7 @@ async def test_command_method(p4p_subprocess: tuple[str, Queue]): 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) + await ctxt.put(f"{pv_prefix}:Child:1:I", True) assert (await i_values.get()).raw.value is True await asyncio.sleep(0.2) @@ -146,7 +155,7 @@ async def test_command_method(p4p_subprocess: tuple[str, Queue]): assert j_values.empty() # Second run succeeds - await ctxt.put(f"{pv_prefix}:Child1:I", True) + await ctxt.put(f"{pv_prefix}:Child:1:I", True) assert (await i_values.get()).raw.value is True await asyncio.sleep(0.2) after_command_value = (await i_values.get()).raw @@ -266,6 +275,10 @@ class SomeController(Controller): controller = SomeController() + sub_controller_vector = ControllerVector({i: ChildController() for i in range(3)}) + + controller.add_sub_controller("child", sub_controller_vector) + sub_controller = ChildController() controller.child0 = sub_controller sub_controller.child_child = ChildChildController() @@ -291,13 +304,21 @@ class SomeController(Controller): ctxt = ThreadContext("pva") - controller_pvi, child_controller_pvi, child_child_controller_pvi = [], [], [] + ( + controller_pvi, + child_vector_controller_pvi, + child_child_controller_pvi, + child_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_vector_controller_monitor = ctxt.monitor( + f"{pv_prefix}:Child:PVI", child_vector_controller_pvi.append ) child_child_controller_monitor = ctxt.monitor( - f"{pv_prefix}:Child0:ChildChild:PVI", child_child_controller_pvi.append + f"{pv_prefix}:Child:0:PVI", child_child_controller_pvi.append + ) + child_child_child_controller_monitor = ctxt.monitor( + f"{pv_prefix}Child:0:ChildChild:PVI", child_child_child_controller_pvi.append ) serve = asyncio.ensure_future(fastcs.serve(interactive=False)) @@ -309,8 +330,9 @@ class SomeController(Controller): ... finally: controller_monitor.close() - child_controller_monitor.close() + child_vector_controller_monitor.close() child_child_controller_monitor.close() + child_child_child_controller_monitor.close() serve.cancel() assert len(controller_pvi) == 1 @@ -325,21 +347,18 @@ class SomeController(Controller): "value": { "additional_child": {"d": f"{pv_prefix}:AdditionalChild:PVI"}, "another_child": {"d": f"{pv_prefix}:AnotherChild:PVI"}, - "another_attr0": {"rw": f"{pv_prefix}:AnotherAttr0"}, - "another_attr1000": {"rw": f"{pv_prefix}:AnotherAttr1000"}, + "another_attr_0": {"rw": f"{pv_prefix}:AnotherAttr0"}, + "another_attr_1000": {"rw": f"{pv_prefix}:AnotherAttr1000"}, "a_third_attr": {"w": f"{pv_prefix}:AThirdAttr"}, - "attr1": {"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", - } - }, + "attr_1": {"rw": f"{pv_prefix}:Attr1"}, + "child": {"d": f"{pv_prefix}:Child:PVI"}, + "child0": {"d": f"{pv_prefix}:Child0:PVI"}, + "child1": {"d": f"{pv_prefix}:Child1:PVI"}, + "child2": {"d": f"{pv_prefix}:Child2:PVI"}, }, } - assert len(child_controller_pvi) == 1 - assert child_controller_pvi[0].todict() == { + assert len(child_vector_controller_pvi) == 1 + assert child_vector_controller_pvi[0].todict() == { "alarm": {"message": "", "severity": 0, "status": 0}, "display": {"description": ""}, "timeStamp": { @@ -348,11 +367,9 @@ class SomeController(Controller): "userTag": 0, }, "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"}, + "__0": {"d": f"{pv_prefix}:Child:0:PVI"}, + "__1": {"d": f"{pv_prefix}:Child:1:PVI"}, + "__2": {"d": f"{pv_prefix}:Child:2:PVI"}, }, } assert len(child_child_controller_pvi) == 1 @@ -365,8 +382,10 @@ class SomeController(Controller): "userTag": 0, }, "value": { - "attr_e": {"rw": f"{pv_prefix}:Child0:ChildChild:AttrE"}, - "attr_f": {"r": f"{pv_prefix}:Child0:ChildChild:AttrF"}, + "attr_c": {"w": f"{pv_prefix}:Child:0:AttrC"}, + "attr_d": { + "w": f"{pv_prefix}:Child:0:AttrD", + }, }, }