diff --git a/pyproject.toml b/pyproject.toml index 966c25ac..0a85bfa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ description = "Control system agnostic framework for building Device support in dependencies = [ "numpy", "pydantic", - "pvi", + "pvi~=0.7.1", "softioc", ] # Add project dependencies here, e.g. ["click", "numpy"] dynamic = ["version"] diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 67f24b02..eac21770 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -7,6 +7,8 @@ class AttrMode(Enum): + """Access mode of an `Attribute`.""" + READ = 1 WRITE = 2 READ_WRITE = 3 @@ -14,12 +16,16 @@ class AttrMode(Enum): @runtime_checkable class Sender(Protocol): + """Protocol for setting the value of an `Attribute`.""" + async def put(self, controller: Any, attr: AttrW, value: Any) -> None: pass @runtime_checkable class Updater(Protocol): + """Protocol for updating the cached readback value of an `Attribute`.""" + update_period: float async def update(self, controller: Any, attr: AttrR) -> None: @@ -28,18 +34,30 @@ async def update(self, controller: Any, attr: AttrR) -> None: @runtime_checkable class Handler(Sender, Updater, Protocol): + """Protocol encapsulating both `Sender` and `Updater`.""" + pass class Attribute(Generic[T]): + """Base FastCS attribute. + + Instances of this class added to a `Controller` will be used by the backend. + """ + def __init__( - self, datatype: DataType[T], access_mode: AttrMode, handler: Any = None + self, + datatype: DataType[T], + access_mode: AttrMode, + group: str | None = None, + handler: Any = None, ) -> None: assert ( datatype.dtype in ATTRIBUTE_TYPES ), f"Attr type must be one of {ATTRIBUTE_TYPES}, received type {datatype.dtype}" self._datatype: DataType[T] = datatype self._access_mode: AttrMode = access_mode + self._group = group @property def datatype(self) -> DataType[T]: @@ -53,15 +71,22 @@ def dtype(self) -> type[T]: def access_mode(self) -> AttrMode: return self._access_mode + @property + def group(self) -> str | None: + return self._group + class AttrR(Attribute[T]): + """A read-only `Attribute`.""" + def __init__( self, datatype: DataType[T], access_mode=AttrMode.READ, + group: str | None = None, handler: Updater | None = None, ) -> None: - super().__init__(datatype, access_mode, handler) # type: ignore + super().__init__(datatype, access_mode, group, handler) # type: ignore self._value: T = datatype.dtype() self._update_callback: AttrCallback[T] | None = None self._updater = handler @@ -84,13 +109,16 @@ def updater(self) -> Updater | None: class AttrW(Attribute[T]): + """A write-only `Attribute`.""" + def __init__( self, datatype: DataType[T], access_mode=AttrMode.WRITE, + group: str | None = None, handler: Sender | None = None, ) -> None: - super().__init__(datatype, access_mode, handler) # type: ignore + super().__init__(datatype, access_mode, group, handler) # type: ignore self._process_callback: AttrCallback[T] | None = None self._write_display_callback: AttrCallback[T] | None = None self._sender = handler @@ -120,13 +148,16 @@ def sender(self) -> Sender | None: class AttrRW(AttrW[T], AttrR[T]): + """A read-write `Attribute`.""" + def __init__( self, datatype: DataType[T], access_mode=AttrMode.READ_WRITE, + group: str | None = None, handler: Handler | None = None, ) -> None: - super().__init__(datatype, access_mode, handler) # type: ignore + super().__init__(datatype, access_mode, group, handler) # type: ignore async def process(self, value: T) -> None: await self.set(value) diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/backends/epics/gui.py index b925a854..09412e41 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/backends/epics/gui.py @@ -2,8 +2,7 @@ from enum import Enum from pathlib import Path -from pvi._format.base import Formatter -from pvi._yaml_utils import deserialize_yaml +from pvi._format.dls import DLSFormatter from pvi.device import ( LED, CheckBox, @@ -16,6 +15,7 @@ SignalRW, SignalW, SignalX, + SubScreen, TextFormat, TextRead, TextWrite, @@ -24,11 +24,10 @@ ) from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.cs_methods import Command from fastcs.datatypes import Bool, DataType, Float, Int, String from fastcs.exceptions import FastCSException -from fastcs.mapping import Mapping - -FORMATTER_YAML = Path.cwd() / ".." / "pvi" / "formatters" / "dls.bob.pvi.formatter.yaml" +from fastcs.mapping import Mapping, SingleMapping class EpicsGUIFormat(Enum): @@ -83,26 +82,32 @@ def _get_write_widget(datatype: DataType) -> WriteWidget: @classmethod def _get_attribute_component(cls, attr_path: str, name: str, attribute: Attribute): pv = cls._get_pv(attr_path, name) - name = name.title().replace("_", " ") + name = name.title().replace("_", "") match attribute: case AttrRW(): read_widget = cls._get_read_widget(attribute.datatype) write_widget = cls._get_write_widget(attribute.datatype) - return SignalRW(name, pv, write_widget, pv + "_RBV", read_widget) + return SignalRW( + name=name, + pv=pv, + widget=write_widget, + read_pv=pv + "_RBV", + read_widget=read_widget, + ) case AttrR(): read_widget = cls._get_read_widget(attribute.datatype) - return SignalR(name, pv, read_widget) + return SignalR(name=name, pv=pv, widget=read_widget) case AttrW(): write_widget = cls._get_write_widget(attribute.datatype) - return SignalW(name, pv, TextWrite()) + return SignalW(name=name, pv=pv, widget=TextWrite()) @classmethod def _get_command_component(cls, attr_path: str, name: str): pv = cls._get_pv(attr_path, name) - name = name.title().replace("_", " ") + name = name.title().replace("_", "") - return SignalX(name, pv, value=1) + return SignalX(name=name, pv=pv, value="1") def create_gui(self, options: EpicsGUIOptions | None = None) -> None: if options is None: @@ -113,29 +118,60 @@ def create_gui(self, options: EpicsGUIOptions | None = None) -> None: assert options.output_path.suffix == options.file_format.value - formatter = deserialize_yaml(Formatter, FORMATTER_YAML) + formatter = DLSFormatter() - components: Tree[Component] = [] - for single_mapping in self._mapping.get_controller_mappings(): - attr_path = single_mapping.controller.path - - group_name = type(single_mapping.controller).__name__ + " " + attr_path - group_children: list[Component] = [] - - for attr_name, attribute in single_mapping.attributes.items(): - group_children.append( - self._get_attribute_component( - attr_path, - attr_name, - attribute, - ) - ) + controller_mapping = self._mapping.get_controller_mappings()[0] + sub_controller_mappings = self._mapping.get_controller_mappings()[1:] - for name in single_mapping.command_methods: - group_children.append(self._get_command_component(attr_path, name)) + components = self.extract_mapping_components(controller_mapping) - components.append(Group(group_name, Grid(), group_children)) + for sub_controller_mapping in sub_controller_mappings: + components.append( + Group( + name=sub_controller_mapping.controller.path, + layout=SubScreen(), + children=self.extract_mapping_components(sub_controller_mapping), + ) + ) - device = Device("Simple Device", children=components) + device = Device(label="Simple Device", children=components) formatter.format(device, "MY-DEVICE-PREFIX", options.output_path) + + def extract_mapping_components(self, mapping: SingleMapping) -> list[Component]: + components: Tree[Component] = [] + attr_path = mapping.controller.path + + groups: dict[str, list[Component]] = {} + for attr_name, attribute in mapping.attributes.items(): + signal = self._get_attribute_component( + attr_path, + attr_name, + attribute, + ) + + match attribute: + case Attribute(group=group) if group is not None: + if group not in groups: + groups[group] = [] + + groups[group].append(signal) + case _: + components.append(signal) + + for name, command in mapping.command_methods.items(): + signal = self._get_command_component(attr_path, name) + + match command: + case Command(group=group) if group is not None: + if group not in groups: + groups[group] = [] + + groups[group].append(signal) + case _: + components.append(signal) + + for name, children in groups.items(): + components.append(Group(name=name, layout=Grid(), children=children)) + + return components diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index cf37e531..783bcbaf 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -23,6 +23,14 @@ def _bind_attrs(self) -> None: class Controller(BaseController): + """Top-level controller for a device. + + This is the primary class for implementing device support in FastCS. Instances of + this class can be loaded into a backend to access its `Attribute`s. The backend can + then perform a specific function with the set of `Attributes`, such as generating a + UI or creating parameters for a control system. + """ + def __init__(self) -> None: super().__init__() self.__sub_controllers: list[SubController] = [] @@ -38,5 +46,11 @@ def get_sub_controllers(self) -> list[SubController]: class SubController(BaseController): + """A subordinate to a `Controller` for managing a subset of a device. + + An instance of this class can be registered with a parent `Controller` to include it + as part of a larger device. + """ + def __init__(self, path: str) -> None: super().__init__(path) diff --git a/src/fastcs/cs_methods.py b/src/fastcs/cs_methods.py index 20cc4b37..315c0603 100644 --- a/src/fastcs/cs_methods.py +++ b/src/fastcs/cs_methods.py @@ -8,7 +8,7 @@ class Method: - def __init__(self, fn: Callable) -> None: + def __init__(self, fn: Callable, *, group: str | None = None) -> None: self._docstring = getdoc(fn) sig = signature(fn, eval_str=True) @@ -17,6 +17,7 @@ def __init__(self, fn: Callable) -> None: self._validate(fn) self._fn = fn + self._group = group def _validate(self, fn: Callable) -> None: if self.return_type not in (None, Signature.empty): @@ -41,6 +42,10 @@ def docstring(self): def fn(self): return self._fn + @property + def group(self): + return self._group + class Scan(Method): def __init__(self, fn: Callable, period) -> None: @@ -71,8 +76,8 @@ def _validate(self, fn: Callable) -> None: class Command(Method): - def __init__(self, fn: Callable) -> None: - super().__init__(fn) + def __init__(self, fn: Callable, *, group: str | None = None) -> None: + super().__init__(fn, group=group) def _validate(self, fn: Callable) -> None: super()._validate(fn) diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py index 0a2941ea..3b6c4113 100644 --- a/src/fastcs/datatypes.py +++ b/src/fastcs/datatypes.py @@ -12,6 +12,8 @@ class DataType(Generic[T]): + """Generic datatype mapping to a python type, with additional metadata.""" + @property @abstractmethod def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars @@ -20,6 +22,8 @@ def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars @dataclass(frozen=True) class Int(DataType[int]): + """`DataType` mapping to builtin `int`.""" + @property def dtype(self) -> type[int]: return int @@ -27,6 +31,8 @@ def dtype(self) -> type[int]: @dataclass(frozen=True) class Float(DataType[float]): + """`DataType` mapping to builtin `float`.""" + prec: int = 2 @property @@ -36,6 +42,8 @@ def dtype(self) -> type[float]: @dataclass(frozen=True) class Bool(DataType[bool]): + """`DataType` mapping to builtin `bool`.""" + znam: str = "OFF" onam: str = "ON" @@ -46,6 +54,8 @@ def dtype(self) -> type[bool]: @dataclass(frozen=True) class String(DataType[str]): + """`DataType` mapping to builtin `str`.""" + @property def dtype(self) -> type[str]: return str diff --git a/src/fastcs/wrappers.py b/src/fastcs/wrappers.py index 9d45e4cc..5451672d 100644 --- a/src/fastcs/wrappers.py +++ b/src/fastcs/wrappers.py @@ -26,6 +26,16 @@ def put(fn) -> Any: return fn -def command(fn) -> Any: - fn.fastcs_method = Command(fn) - return fn +def command(*, group: str | None = None) -> Any: + """Decorator to map a `Controller` method into a `Command`. + + Args: + group: Group to display the widget for this command in on the UI + + """ + + def wrapper(fn): + fn.fastcs_method = Command(fn, group=group) + return fn + + return wrapper