Skip to content

Commit bf41c16

Browse files
authored
Merge pull request #19 from DiamondLightSource/ui-groups
Implement grouping of Attributes on generated UIs
2 parents 497316c + a0944fb commit bf41c16

File tree

7 files changed

+148
-42
lines changed

7 files changed

+148
-42
lines changed

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ description = "Control system agnostic framework for building Device support in
1414
dependencies = [
1515
"numpy",
1616
"pydantic",
17-
"pvi",
17+
"pvi~=0.7.1",
1818
"softioc",
1919
] # Add project dependencies here, e.g. ["click", "numpy"]
2020
dynamic = ["version"]

src/fastcs/attributes.py

+35-4
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,25 @@
77

88

99
class AttrMode(Enum):
10+
"""Access mode of an `Attribute`."""
11+
1012
READ = 1
1113
WRITE = 2
1214
READ_WRITE = 3
1315

1416

1517
@runtime_checkable
1618
class Sender(Protocol):
19+
"""Protocol for setting the value of an `Attribute`."""
20+
1721
async def put(self, controller: Any, attr: AttrW, value: Any) -> None:
1822
pass
1923

2024

2125
@runtime_checkable
2226
class Updater(Protocol):
27+
"""Protocol for updating the cached readback value of an `Attribute`."""
28+
2329
update_period: float
2430

2531
async def update(self, controller: Any, attr: AttrR) -> None:
@@ -28,18 +34,30 @@ async def update(self, controller: Any, attr: AttrR) -> None:
2834

2935
@runtime_checkable
3036
class Handler(Sender, Updater, Protocol):
37+
"""Protocol encapsulating both `Sender` and `Updater`."""
38+
3139
pass
3240

3341

3442
class Attribute(Generic[T]):
43+
"""Base FastCS attribute.
44+
45+
Instances of this class added to a `Controller` will be used by the backend.
46+
"""
47+
3548
def __init__(
36-
self, datatype: DataType[T], access_mode: AttrMode, handler: Any = None
49+
self,
50+
datatype: DataType[T],
51+
access_mode: AttrMode,
52+
group: str | None = None,
53+
handler: Any = None,
3754
) -> None:
3855
assert (
3956
datatype.dtype in ATTRIBUTE_TYPES
4057
), f"Attr type must be one of {ATTRIBUTE_TYPES}, received type {datatype.dtype}"
4158
self._datatype: DataType[T] = datatype
4259
self._access_mode: AttrMode = access_mode
60+
self._group = group
4361

4462
@property
4563
def datatype(self) -> DataType[T]:
@@ -53,15 +71,22 @@ def dtype(self) -> type[T]:
5371
def access_mode(self) -> AttrMode:
5472
return self._access_mode
5573

74+
@property
75+
def group(self) -> str | None:
76+
return self._group
77+
5678

5779
class AttrR(Attribute[T]):
80+
"""A read-only `Attribute`."""
81+
5882
def __init__(
5983
self,
6084
datatype: DataType[T],
6185
access_mode=AttrMode.READ,
86+
group: str | None = None,
6287
handler: Updater | None = None,
6388
) -> None:
64-
super().__init__(datatype, access_mode, handler) # type: ignore
89+
super().__init__(datatype, access_mode, group, handler) # type: ignore
6590
self._value: T = datatype.dtype()
6691
self._update_callback: AttrCallback[T] | None = None
6792
self._updater = handler
@@ -84,13 +109,16 @@ def updater(self) -> Updater | None:
84109

85110

86111
class AttrW(Attribute[T]):
112+
"""A write-only `Attribute`."""
113+
87114
def __init__(
88115
self,
89116
datatype: DataType[T],
90117
access_mode=AttrMode.WRITE,
118+
group: str | None = None,
91119
handler: Sender | None = None,
92120
) -> None:
93-
super().__init__(datatype, access_mode, handler) # type: ignore
121+
super().__init__(datatype, access_mode, group, handler) # type: ignore
94122
self._process_callback: AttrCallback[T] | None = None
95123
self._write_display_callback: AttrCallback[T] | None = None
96124
self._sender = handler
@@ -120,13 +148,16 @@ def sender(self) -> Sender | None:
120148

121149

122150
class AttrRW(AttrW[T], AttrR[T]):
151+
"""A read-write `Attribute`."""
152+
123153
def __init__(
124154
self,
125155
datatype: DataType[T],
126156
access_mode=AttrMode.READ_WRITE,
157+
group: str | None = None,
127158
handler: Handler | None = None,
128159
) -> None:
129-
super().__init__(datatype, access_mode, handler) # type: ignore
160+
super().__init__(datatype, access_mode, group, handler) # type: ignore
130161

131162
async def process(self, value: T) -> None:
132163
await self.set(value)

src/fastcs/backends/epics/gui.py

+67-31
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
from enum import Enum
33
from pathlib import Path
44

5-
from pvi._format.base import Formatter
6-
from pvi._yaml_utils import deserialize_yaml
5+
from pvi._format.dls import DLSFormatter
76
from pvi.device import (
87
LED,
98
CheckBox,
@@ -16,6 +15,7 @@
1615
SignalRW,
1716
SignalW,
1817
SignalX,
18+
SubScreen,
1919
TextFormat,
2020
TextRead,
2121
TextWrite,
@@ -24,11 +24,10 @@
2424
)
2525

2626
from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
27+
from fastcs.cs_methods import Command
2728
from fastcs.datatypes import Bool, DataType, Float, Int, String
2829
from fastcs.exceptions import FastCSException
29-
from fastcs.mapping import Mapping
30-
31-
FORMATTER_YAML = Path.cwd() / ".." / "pvi" / "formatters" / "dls.bob.pvi.formatter.yaml"
30+
from fastcs.mapping import Mapping, SingleMapping
3231

3332

3433
class EpicsGUIFormat(Enum):
@@ -83,26 +82,32 @@ def _get_write_widget(datatype: DataType) -> WriteWidget:
8382
@classmethod
8483
def _get_attribute_component(cls, attr_path: str, name: str, attribute: Attribute):
8584
pv = cls._get_pv(attr_path, name)
86-
name = name.title().replace("_", " ")
85+
name = name.title().replace("_", "")
8786

8887
match attribute:
8988
case AttrRW():
9089
read_widget = cls._get_read_widget(attribute.datatype)
9190
write_widget = cls._get_write_widget(attribute.datatype)
92-
return SignalRW(name, pv, write_widget, pv + "_RBV", read_widget)
91+
return SignalRW(
92+
name=name,
93+
pv=pv,
94+
widget=write_widget,
95+
read_pv=pv + "_RBV",
96+
read_widget=read_widget,
97+
)
9398
case AttrR():
9499
read_widget = cls._get_read_widget(attribute.datatype)
95-
return SignalR(name, pv, read_widget)
100+
return SignalR(name=name, pv=pv, widget=read_widget)
96101
case AttrW():
97102
write_widget = cls._get_write_widget(attribute.datatype)
98-
return SignalW(name, pv, TextWrite())
103+
return SignalW(name=name, pv=pv, widget=TextWrite())
99104

100105
@classmethod
101106
def _get_command_component(cls, attr_path: str, name: str):
102107
pv = cls._get_pv(attr_path, name)
103-
name = name.title().replace("_", " ")
108+
name = name.title().replace("_", "")
104109

105-
return SignalX(name, pv, value=1)
110+
return SignalX(name=name, pv=pv, value="1")
106111

107112
def create_gui(self, options: EpicsGUIOptions | None = None) -> None:
108113
if options is None:
@@ -113,29 +118,60 @@ def create_gui(self, options: EpicsGUIOptions | None = None) -> None:
113118

114119
assert options.output_path.suffix == options.file_format.value
115120

116-
formatter = deserialize_yaml(Formatter, FORMATTER_YAML)
121+
formatter = DLSFormatter()
117122

118-
components: Tree[Component] = []
119-
for single_mapping in self._mapping.get_controller_mappings():
120-
attr_path = single_mapping.controller.path
121-
122-
group_name = type(single_mapping.controller).__name__ + " " + attr_path
123-
group_children: list[Component] = []
124-
125-
for attr_name, attribute in single_mapping.attributes.items():
126-
group_children.append(
127-
self._get_attribute_component(
128-
attr_path,
129-
attr_name,
130-
attribute,
131-
)
132-
)
123+
controller_mapping = self._mapping.get_controller_mappings()[0]
124+
sub_controller_mappings = self._mapping.get_controller_mappings()[1:]
133125

134-
for name in single_mapping.command_methods:
135-
group_children.append(self._get_command_component(attr_path, name))
126+
components = self.extract_mapping_components(controller_mapping)
136127

137-
components.append(Group(group_name, Grid(), group_children))
128+
for sub_controller_mapping in sub_controller_mappings:
129+
components.append(
130+
Group(
131+
name=sub_controller_mapping.controller.path,
132+
layout=SubScreen(),
133+
children=self.extract_mapping_components(sub_controller_mapping),
134+
)
135+
)
138136

139-
device = Device("Simple Device", children=components)
137+
device = Device(label="Simple Device", children=components)
140138

141139
formatter.format(device, "MY-DEVICE-PREFIX", options.output_path)
140+
141+
def extract_mapping_components(self, mapping: SingleMapping) -> list[Component]:
142+
components: Tree[Component] = []
143+
attr_path = mapping.controller.path
144+
145+
groups: dict[str, list[Component]] = {}
146+
for attr_name, attribute in mapping.attributes.items():
147+
signal = self._get_attribute_component(
148+
attr_path,
149+
attr_name,
150+
attribute,
151+
)
152+
153+
match attribute:
154+
case Attribute(group=group) if group is not None:
155+
if group not in groups:
156+
groups[group] = []
157+
158+
groups[group].append(signal)
159+
case _:
160+
components.append(signal)
161+
162+
for name, command in mapping.command_methods.items():
163+
signal = self._get_command_component(attr_path, name)
164+
165+
match command:
166+
case Command(group=group) if group is not None:
167+
if group not in groups:
168+
groups[group] = []
169+
170+
groups[group].append(signal)
171+
case _:
172+
components.append(signal)
173+
174+
for name, children in groups.items():
175+
components.append(Group(name=name, layout=Grid(), children=children))
176+
177+
return components

src/fastcs/controller.py

+14
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ def _bind_attrs(self) -> None:
2323

2424

2525
class Controller(BaseController):
26+
"""Top-level controller for a device.
27+
28+
This is the primary class for implementing device support in FastCS. Instances of
29+
this class can be loaded into a backend to access its `Attribute`s. The backend can
30+
then perform a specific function with the set of `Attributes`, such as generating a
31+
UI or creating parameters for a control system.
32+
"""
33+
2634
def __init__(self) -> None:
2735
super().__init__()
2836
self.__sub_controllers: list[SubController] = []
@@ -38,5 +46,11 @@ def get_sub_controllers(self) -> list[SubController]:
3846

3947

4048
class SubController(BaseController):
49+
"""A subordinate to a `Controller` for managing a subset of a device.
50+
51+
An instance of this class can be registered with a parent `Controller` to include it
52+
as part of a larger device.
53+
"""
54+
4155
def __init__(self, path: str) -> None:
4256
super().__init__(path)

src/fastcs/cs_methods.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99

1010
class Method:
11-
def __init__(self, fn: Callable) -> None:
11+
def __init__(self, fn: Callable, *, group: str | None = None) -> None:
1212
self._docstring = getdoc(fn)
1313

1414
sig = signature(fn, eval_str=True)
@@ -17,6 +17,7 @@ def __init__(self, fn: Callable) -> None:
1717
self._validate(fn)
1818

1919
self._fn = fn
20+
self._group = group
2021

2122
def _validate(self, fn: Callable) -> None:
2223
if self.return_type not in (None, Signature.empty):
@@ -41,6 +42,10 @@ def docstring(self):
4142
def fn(self):
4243
return self._fn
4344

45+
@property
46+
def group(self):
47+
return self._group
48+
4449

4550
class Scan(Method):
4651
def __init__(self, fn: Callable, period) -> None:
@@ -71,8 +76,8 @@ def _validate(self, fn: Callable) -> None:
7176

7277

7378
class Command(Method):
74-
def __init__(self, fn: Callable) -> None:
75-
super().__init__(fn)
79+
def __init__(self, fn: Callable, *, group: str | None = None) -> None:
80+
super().__init__(fn, group=group)
7681

7782
def _validate(self, fn: Callable) -> None:
7883
super()._validate(fn)

src/fastcs/datatypes.py

+10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313

1414
class DataType(Generic[T]):
15+
"""Generic datatype mapping to a python type, with additional metadata."""
16+
1517
@property
1618
@abstractmethod
1719
def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars
@@ -20,13 +22,17 @@ def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars
2022

2123
@dataclass(frozen=True)
2224
class Int(DataType[int]):
25+
"""`DataType` mapping to builtin `int`."""
26+
2327
@property
2428
def dtype(self) -> type[int]:
2529
return int
2630

2731

2832
@dataclass(frozen=True)
2933
class Float(DataType[float]):
34+
"""`DataType` mapping to builtin `float`."""
35+
3036
prec: int = 2
3137

3238
@property
@@ -36,6 +42,8 @@ def dtype(self) -> type[float]:
3642

3743
@dataclass(frozen=True)
3844
class Bool(DataType[bool]):
45+
"""`DataType` mapping to builtin `bool`."""
46+
3947
znam: str = "OFF"
4048
onam: str = "ON"
4149

@@ -46,6 +54,8 @@ def dtype(self) -> type[bool]:
4654

4755
@dataclass(frozen=True)
4856
class String(DataType[str]):
57+
"""`DataType` mapping to builtin `str`."""
58+
4959
@property
5060
def dtype(self) -> type[str]:
5161
return str

0 commit comments

Comments
 (0)