Skip to content

Commit 751233b

Browse files
authored
Merge pull request #40 from DiamondLightSource/dropdowns
Create mbb records for enum Attributes
2 parents 881a7cd + dda9e45 commit 751233b

File tree

9 files changed

+437
-37
lines changed

9 files changed

+437
-37
lines changed

src/fastcs/attributes.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(
5151
access_mode: AttrMode,
5252
group: str | None = None,
5353
handler: Any = None,
54+
allowed_values: list[T] | None = None,
5455
) -> None:
5556
assert (
5657
datatype.dtype in ATTRIBUTE_TYPES
@@ -59,6 +60,7 @@ def __init__(
5960
self._access_mode: AttrMode = access_mode
6061
self._group = group
6162
self.enabled = True
63+
self._allowed_values: list[T] | None = allowed_values
6264

6365
@property
6466
def datatype(self) -> DataType[T]:
@@ -76,6 +78,10 @@ def access_mode(self) -> AttrMode:
7678
def group(self) -> str | None:
7779
return self._group
7880

81+
@property
82+
def allowed_values(self) -> list[T] | None:
83+
return self._allowed_values
84+
7985

8086
class AttrR(Attribute[T]):
8187
"""A read-only ``Attribute``."""
@@ -86,8 +92,15 @@ def __init__(
8692
access_mode=AttrMode.READ,
8793
group: str | None = None,
8894
handler: Updater | None = None,
95+
allowed_values: list[T] | None = None,
8996
) -> None:
90-
super().__init__(datatype, access_mode, group, handler) # type: ignore
97+
super().__init__(
98+
datatype, # type: ignore
99+
access_mode,
100+
group,
101+
handler,
102+
allowed_values=allowed_values, # type: ignore
103+
)
91104
self._value: T = datatype.dtype()
92105
self._update_callback: AttrCallback[T] | None = None
93106
self._updater = handler
@@ -118,19 +131,19 @@ def __init__(
118131
access_mode=AttrMode.WRITE,
119132
group: str | None = None,
120133
handler: Sender | None = None,
121-
allowed_values: list[str] | None = None,
134+
allowed_values: list[T] | None = None,
122135
) -> None:
123-
super().__init__(datatype, access_mode, group, handler) # type: ignore
136+
super().__init__(
137+
datatype, # type: ignore
138+
access_mode,
139+
group,
140+
handler,
141+
allowed_values=allowed_values, # type: ignore
142+
)
124143
self._process_callback: AttrCallback[T] | None = None
125144
self._write_display_callback: AttrCallback[T] | None = None
126145
self._sender = handler
127146

128-
self._allowed_values = allowed_values
129-
130-
@property
131-
def allowed_values(self) -> list[str] | None:
132-
return self._allowed_values
133-
134147
async def process(self, value: T) -> None:
135148
await self.process_without_display_update(value)
136149
await self.update_display_without_process(value)
@@ -166,9 +179,15 @@ def __init__(
166179
access_mode=AttrMode.READ_WRITE,
167180
group: str | None = None,
168181
handler: Handler | None = None,
169-
allowed_values: list[str] | None = None,
182+
allowed_values: list[T] | None = None,
170183
) -> None:
171-
super().__init__(datatype, access_mode, group, handler, allowed_values) # type: ignore
184+
super().__init__(
185+
datatype, # type: ignore
186+
access_mode,
187+
group,
188+
handler,
189+
allowed_values, # type: ignore
190+
)
172191

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

src/fastcs/backends/epics/ioc.py

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@
88
from softioc.pythonSoftIoc import RecordWrapper
99

1010
from fastcs.attributes import AttrR, AttrRW, AttrW
11+
from fastcs.backends.epics.util import (
12+
MBB_STATE_FIELDS,
13+
attr_is_enum,
14+
enum_index_to_value,
15+
enum_value_to_index,
16+
)
1117
from fastcs.controller import BaseController
12-
from fastcs.datatypes import Bool, DataType, Float, Int, String
18+
from fastcs.datatypes import Bool, Float, Int, String, T
1319
from fastcs.exceptions import FastCSException
1420
from fastcs.mapping import Mapping
1521

@@ -148,20 +154,30 @@ def _create_and_link_attribute_pvs(pv_prefix: str, mapping: Mapping) -> None:
148154

149155

150156
def _create_and_link_read_pv(
151-
pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrR
157+
pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrR[T]
152158
) -> None:
153-
record = _get_input_record(f"{pv_prefix}:{pv_name}", attribute.datatype)
159+
if attr_is_enum(attribute):
154160

161+
async def async_record_set(value: T):
162+
record.set(enum_value_to_index(attribute, value))
163+
else:
164+
165+
async def async_record_set(value: T): # type: ignore
166+
record.set(value)
167+
168+
record = _get_input_record(f"{pv_prefix}:{pv_name}", attribute)
155169
_add_attr_pvi_info(record, pv_prefix, attr_name, "r")
156170

157-
async def async_wrapper(v):
158-
record.set(v)
171+
attribute.set_update_callback(async_record_set)
159172

160-
attribute.set_update_callback(async_wrapper)
161173

174+
def _get_input_record(pv: str, attribute: AttrR) -> RecordWrapper:
175+
if attr_is_enum(attribute):
176+
# https://github.com/python/mypy/issues/16789
177+
state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False)) # type: ignore
178+
return builder.mbbIn(pv, **state_keys)
162179

163-
def _get_input_record(pv: str, datatype: DataType) -> RecordWrapper:
164-
match datatype:
180+
match attribute.datatype:
165181
case Bool(znam, onam):
166182
return builder.boolIn(pv, ZNAM=znam, ONAM=onam)
167183
case Int():
@@ -171,28 +187,46 @@ def _get_input_record(pv: str, datatype: DataType) -> RecordWrapper:
171187
case String():
172188
return builder.longStringIn(pv)
173189
case _:
174-
raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}")
190+
raise FastCSException(
191+
f"Unsupported type {type(attribute.datatype)}: {attribute.datatype}"
192+
)
175193

176194

177195
def _create_and_link_write_pv(
178-
pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW
196+
pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW[T]
179197
) -> None:
198+
if attr_is_enum(attribute):
199+
200+
async def on_update(value):
201+
await attribute.process_without_display_update(
202+
enum_index_to_value(attribute, value)
203+
)
204+
205+
async def async_write_display(value: T):
206+
record.set(enum_value_to_index(attribute, value), process=False)
207+
208+
else:
209+
210+
async def on_update(value):
211+
await attribute.process_without_display_update(value)
212+
213+
async def async_write_display(value: T): # type: ignore
214+
record.set(value, process=False)
215+
180216
record = _get_output_record(
181-
f"{pv_prefix}:{pv_name}",
182-
attribute.datatype,
183-
on_update=attribute.process_without_display_update,
217+
f"{pv_prefix}:{pv_name}", attribute, on_update=on_update
184218
)
185-
186219
_add_attr_pvi_info(record, pv_prefix, attr_name, "w")
187220

188-
async def async_wrapper(v):
189-
record.set(v, process=False)
221+
attribute.set_write_display_callback(async_write_display)
190222

191-
attribute.set_write_display_callback(async_wrapper)
192223

224+
def _get_output_record(pv: str, attribute: AttrW, on_update: Callable) -> Any:
225+
if attr_is_enum(attribute):
226+
state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False)) # type: ignore
227+
return builder.mbbOut(pv, always_update=True, on_update=on_update, **state_keys)
193228

194-
def _get_output_record(pv: str, datatype: DataType, on_update: Callable) -> Any:
195-
match datatype:
229+
match attribute.datatype:
196230
case Bool(znam, onam):
197231
return builder.boolOut(
198232
pv,
@@ -208,7 +242,9 @@ def _get_output_record(pv: str, datatype: DataType, on_update: Callable) -> Any:
208242
case String():
209243
return builder.longStringOut(pv, always_update=True, on_update=on_update)
210244
case _:
211-
raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}")
245+
raise FastCSException(
246+
f"Unsupported type {type(attribute.datatype)}: {attribute.datatype}"
247+
)
212248

213249

214250
def _create_and_link_command_pvs(pv_prefix: str, mapping: Mapping) -> None:

src/fastcs/backends/epics/util.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from fastcs.attributes import Attribute
2+
from fastcs.datatypes import String, T
3+
4+
_MBB_FIELD_PREFIXES = (
5+
"ZR",
6+
"ON",
7+
"TW",
8+
"TH",
9+
"FR",
10+
"FV",
11+
"SX",
12+
"SV",
13+
"EI",
14+
"NI",
15+
"TE",
16+
"EL",
17+
"TV",
18+
"TT",
19+
"FT",
20+
"FF",
21+
)
22+
23+
MBB_STATE_FIELDS = tuple(f"{p}ST" for p in _MBB_FIELD_PREFIXES)
24+
MBB_VALUE_FIELDS = tuple(f"{p}VL" for p in _MBB_FIELD_PREFIXES)
25+
MBB_MAX_CHOICES = len(_MBB_FIELD_PREFIXES)
26+
27+
28+
def attr_is_enum(attribute: Attribute) -> bool:
29+
"""Check if the `Attribute` has a `String` datatype and has `allowed_values` set.
30+
31+
Args:
32+
attribute: The `Attribute` to check
33+
34+
Returns:
35+
`True` if `Attribute` is an enum, else `False`
36+
37+
"""
38+
match attribute:
39+
case Attribute(
40+
datatype=String(), allowed_values=allowed_values
41+
) if allowed_values is not None and len(allowed_values) <= MBB_MAX_CHOICES:
42+
return True
43+
case _:
44+
return False
45+
46+
47+
def enum_value_to_index(attribute: Attribute[T], value: T) -> int:
48+
"""Convert the given value to the index within the allowed_values of the Attribute
49+
50+
Args:
51+
`attribute`: The attribute
52+
`value`: The value to convert
53+
54+
Returns:
55+
The index of the `value`
56+
57+
Raises:
58+
ValueError: If `attribute` has no allowed values or `value` is not a valid
59+
option
60+
61+
"""
62+
if attribute.allowed_values is None:
63+
raise ValueError(
64+
"Cannot convert value to index for Attribute without allowed values"
65+
)
66+
67+
try:
68+
return attribute.allowed_values.index(value)
69+
except ValueError:
70+
raise ValueError(
71+
f"{value} not in allowed values of {attribute}: {attribute.allowed_values}"
72+
) from None
73+
74+
75+
def enum_index_to_value(attribute: Attribute[T], index: int) -> T:
76+
"""Lookup the value from the allowed_values of an attribute at the given index.
77+
78+
Parameters:
79+
attribute: The `Attribute` to lookup the index from
80+
index: The index of the value to retrieve
81+
82+
Returns:
83+
The value at the specified index in the allowed values list.
84+
85+
Raises:
86+
IndexError: If the index is out of bounds
87+
88+
"""
89+
if attribute.allowed_values is None:
90+
raise ValueError(
91+
"Cannot lookup value by index for Attribute without allowed values"
92+
)
93+
94+
try:
95+
return attribute.allowed_values[index]
96+
except IndexError:
97+
raise IndexError(
98+
f"Invalid index {index} into allowed values: {attribute.allowed_values}"
99+
) from None

tests/backends/epics/test_gui.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def test_get_components(mapping):
2929

3030
components = gui.extract_mapping_components(mapping.get_controller_mappings()[0])
3131
assert components == [
32+
SignalR(name="BigEnum", read_pv="DEVICE:BigEnum", read_widget=TextRead()),
3233
SignalR(name="ReadBool", read_pv="DEVICE:ReadBool", read_widget=LED()),
3334
SignalR(
3435
name="ReadInt",

0 commit comments

Comments
 (0)