From 0d81daf7aac715ac25d8f5d95e84b4158e796a8d Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Wed, 17 Jan 2024 09:35:45 +0000 Subject: [PATCH] Implement grouping of Attributes on generated UIs Attributes and Methods take an optional group to be placed in. If group is unset it will appear at the root of the UI. SubControllers are displayed on a SubScreen of the parent Controller. --- src/fastcs/attributes.py | 21 ++++++--- src/fastcs/backends/epics/gui.py | 77 +++++++++++++++++++++++--------- src/fastcs/backends/epics/ioc.py | 2 +- src/fastcs/cs_methods.py | 13 ++++-- src/fastcs/wrappers.py | 18 ++++++-- 5 files changed, 95 insertions(+), 36 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 3d916025..d43d332e 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Generic, Protocol, runtime_checkable +from typing import Any, Generic, Optional, Protocol, runtime_checkable from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T @@ -51,13 +51,17 @@ class Attribute(Generic[T]): """ def __init__( - self, datatype: DataType[T], access_mode: AttrMode, handler: Any = None + self, + datatype: DataType[T], + access_mode: AttrMode, + group: Optional[str] = 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]: @@ -71,6 +75,10 @@ def dtype(self) -> type[T]: def access_mode(self) -> AttrMode: return self._access_mode + @property + def group(self) -> Optional[str]: + return self._group + class AttrR(Attribute[T]): """A read-only `Attribute`.""" @@ -79,9 +87,10 @@ def __init__( self, datatype: DataType[T], access_mode=AttrMode.READ, + group: Optional[str] = None, handler: Updater | None = None, ) -> None: - super().__init__(datatype, access_mode, handler) # type: ignore + super().__init__(datatype, access_mode, group) # type: ignore self._value: T = datatype.dtype() self._update_callback: AttrCallback[T] | None = None self._updater = handler @@ -110,9 +119,10 @@ def __init__( self, datatype: DataType[T], access_mode=AttrMode.WRITE, + group: Optional[str] = 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 @@ -148,9 +158,10 @@ def __init__( self, datatype: DataType[T], access_mode=AttrMode.READ_WRITE, + group: Optional[str] = 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..23a3087b 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/backends/epics/gui.py @@ -1,9 +1,9 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path +from typing import Callable -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 +16,7 @@ SignalRW, SignalW, SignalX, + SubScreen, TextFormat, TextRead, TextWrite, @@ -24,6 +25,7 @@ ) 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 @@ -113,29 +115,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(label_width=150, widget_width=200) - 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( + sub_controller_mapping.controller.path, + SubScreen(), + self.extract_mapping_components(sub_controller_mapping), + ) + ) device = Device("Simple Device", children=components) - formatter.format(device, "MY-DEVICE-PREFIX", options.output_path) + formatter.format(device, "GARY", options.output_path) + + def extract_mapping_components(self, mapping: Mapping) -> 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, Grid(), children)) + + return components diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index 583eb0a8..fa4fec11 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -124,7 +124,7 @@ def run(self, options: EpicsIOCOptions | None = None) -> None: backend = Backend(self._mapping, dispatcher.loop) # Set the record prefix - builder.SetDeviceName("MY-DEVICE-PREFIX") + builder.SetDeviceName("GARY") _create_and_link_attribute_pvs(self._mapping) diff --git a/src/fastcs/cs_methods.py b/src/fastcs/cs_methods.py index 20cc4b37..33af73a5 100644 --- a/src/fastcs/cs_methods.py +++ b/src/fastcs/cs_methods.py @@ -1,6 +1,6 @@ from asyncio import iscoroutinefunction from inspect import Signature, getdoc, signature -from typing import Awaitable, Callable +from typing import Awaitable, Callable, Optional from .exceptions import FastCSException @@ -8,7 +8,7 @@ class Method: - def __init__(self, fn: Callable) -> None: + def __init__(self, fn: Callable, *, group: Optional[str] = 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: Optional[str] = None) -> None: + super().__init__(fn, group=group) def _validate(self, fn: Callable) -> None: super()._validate(fn) diff --git a/src/fastcs/wrappers.py b/src/fastcs/wrappers.py index 9d45e4cc..67d154dc 100644 --- a/src/fastcs/wrappers.py +++ b/src/fastcs/wrappers.py @@ -1,4 +1,4 @@ -from typing import Any, Protocol, runtime_checkable +from typing import Any, Optional, Protocol, runtime_checkable from .cs_methods import Command, Method, Put, Scan from .exceptions import FastCSException @@ -26,6 +26,16 @@ def put(fn) -> Any: return fn -def command(fn) -> Any: - fn.fastcs_method = Command(fn) - return fn +def command(*, group: Optional[str] = None) -> Any: + """Decorator to turn 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