Skip to content

Commit 8dab77e

Browse files
committed
added a callback to Attribute enabling backend to change values
For example, updating an attribute to have an `Int` with different units will make the epics backend update `EGU` of the corresponding record.
1 parent 9556039 commit 8dab77e

File tree

5 files changed

+102
-43
lines changed

5 files changed

+102
-43
lines changed

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ dependencies = [
1616
"pydantic",
1717
"pvi~=0.10.0",
1818
"pytango",
19-
"softioc",
19+
# This is needed for softioc.device.set_field
20+
"softioc>=4.5.0",
2021
]
2122
dynamic = ["version"]
2223
license.file = "LICENSE"
@@ -42,7 +43,7 @@ dev = [
4243
"tox-direct",
4344
"types-mock",
4445
"aioca",
45-
"p4p",
46+
"p4p>=4.2.0",
4647
]
4748

4849
[project.scripts]
@@ -61,7 +62,7 @@ version_file = "src/fastcs/_version.py"
6162

6263
[tool.pyright]
6364
typeCheckingMode = "standard"
64-
reportMissingImports = false # Ignore missing stubs in imported modules
65+
reportMissingImports = false # Ignore missing stubs in imported modules
6566

6667
[tool.pytest.ini_options]
6768
# Run pytest with all our checkers, and don't spam us with massive tracebacks on error

src/fastcs/attributes.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections.abc import Callable
34
from enum import Enum
45
from typing import Any, Generic, Protocol, runtime_checkable
56

@@ -65,6 +66,10 @@ def __init__(
6566
self._allowed_values: list[T] | None = allowed_values
6667
self.description = description
6768

69+
# A callback to use when setting the datatype to a different value, for example
70+
# changing the units on an int. This should be implemented in the backend.
71+
self._update_datatype_callbacks: list[Callable[[DataType[T]], None]] = []
72+
6873
@property
6974
def datatype(self) -> DataType[T]:
7075
return self._datatype
@@ -85,6 +90,20 @@ def group(self) -> str | None:
8590
def allowed_values(self) -> list[T] | None:
8691
return self._allowed_values
8792

93+
def add_update_datatype_callback(
94+
self, callback: Callable[[DataType[T]], None]
95+
) -> None:
96+
self._update_datatype_callbacks.append(callback)
97+
98+
def update_datatype(self, datatype: DataType[T]) -> None:
99+
if not isinstance(self._datatype, type(datatype)):
100+
raise ValueError(
101+
f"Attribute datatype must be of type {type(self._datatype)}"
102+
)
103+
self._datatype = datatype
104+
for callback in self._update_datatype_callbacks:
105+
callback(datatype)
106+
88107

89108
class AttrR(Attribute[T]):
90109
"""A read-only ``Attribute``."""

src/fastcs/backends/epics/ioc.py

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Callable
2-
from dataclasses import dataclass
2+
from dataclasses import asdict, dataclass
33
from types import MethodType
44
from typing import Any, Literal
55

@@ -15,7 +15,7 @@
1515
enum_value_to_index,
1616
)
1717
from fastcs.controller import BaseController
18-
from fastcs.datatypes import Bool, Float, Int, String, T
18+
from fastcs.datatypes import Bool, DataType, Float, Int, String, T
1919
from fastcs.exceptions import FastCSException
2020
from fastcs.mapping import Mapping
2121

@@ -27,6 +27,25 @@ class EpicsIOCOptions:
2727
terminal: bool = True
2828

2929

30+
DATATYPE_NAME_TO_RECORD_FIELD = {
31+
"prec": "PREC",
32+
"units": "EGU",
33+
"min": "DRVL",
34+
"max": "DRVH",
35+
"min_alarm": "LOPR",
36+
"max_alarm": "HOPR",
37+
"znam": "ZNAM",
38+
"onam": "ONAM",
39+
}
40+
41+
42+
def datatype_to_epics_fields(datatype: DataType) -> dict[str, Any]:
43+
return {
44+
DATATYPE_NAME_TO_RECORD_FIELD[field]: value
45+
for field, value in asdict(datatype).items()
46+
}
47+
48+
3049
class EpicsIOC:
3150
def __init__(self, pv_prefix: str, mapping: Mapping):
3251
_add_pvi_info(f"{pv_prefix}:PVI")
@@ -184,36 +203,38 @@ def _get_input_record(pv: str, attribute: AttrR) -> RecordWrapper:
184203
return builder.mbbIn(pv, **state_keys, **attribute_fields)
185204

186205
match attribute.datatype:
187-
case Bool(znam, onam):
188-
return builder.boolIn(pv, ZNAM=znam, ONAM=onam, **attribute_fields)
189-
case Int(units, min, max, min_alarm, max_alarm):
190-
return builder.longIn(
206+
case Bool():
207+
record = builder.boolIn(
208+
pv, **datatype_to_epics_fields(attribute.datatype), **attribute_fields
209+
)
210+
case Int():
211+
record = builder.longIn(
191212
pv,
192-
EGU=units,
193-
DRVL=min,
194-
DRVH=max,
195-
LOPR=min_alarm,
196-
HOPR=max_alarm,
213+
**datatype_to_epics_fields(attribute.datatype),
197214
**attribute_fields,
198215
)
199-
case Float(prec, units, min, max, min_alarm, max_alarm):
200-
return builder.aIn(
216+
case Float():
217+
record = builder.aIn(
201218
pv,
202-
PREC=prec,
203-
EGU=units,
204-
DRVL=min,
205-
DRVH=max,
206-
LOPR=min_alarm,
207-
HOPR=max_alarm,
219+
**datatype_to_epics_fields(attribute.datatype),
208220
**attribute_fields,
209221
)
210222
case String():
211-
return builder.longStringIn(pv, **attribute_fields)
223+
record = builder.longStringIn(
224+
pv, **datatype_to_epics_fields(attribute.datatype), **attribute_fields
225+
)
212226
case _:
213227
raise FastCSException(
214228
f"Unsupported type {type(attribute.datatype)}: {attribute.datatype}"
215229
)
216230

231+
def datatype_updater(datatype: DataType):
232+
for name, value in datatype_to_epics_fields(datatype).items():
233+
record.set_field(name, value)
234+
235+
attribute.add_update_datatype_callback(datatype_updater)
236+
return record
237+
217238

218239
def _create_and_link_write_pv(
219240
pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW[T]
@@ -262,48 +283,45 @@ def _get_output_record(pv: str, attribute: AttrW, on_update: Callable) -> Any:
262283
)
263284

264285
match attribute.datatype:
265-
case Bool(znam, onam):
266-
return builder.boolOut(
286+
case Bool():
287+
record = builder.boolOut(
267288
pv,
268-
ZNAM=znam,
269-
ONAM=onam,
289+
**datatype_to_epics_fields(attribute.datatype),
270290
always_update=True,
271291
on_update=on_update,
272292
)
273-
case Int(units, min, max, min_alarm, max_alarm):
274-
return builder.longOut(
293+
case Int():
294+
record = builder.longOut(
275295
pv,
276296
always_update=True,
277297
on_update=on_update,
278-
EGU=units,
279-
DRVL=min,
280-
DRVH=max,
281-
LOPR=min_alarm,
282-
HOPR=max_alarm,
298+
**datatype_to_epics_fields(attribute.datatype),
283299
**attribute_fields,
284300
)
285-
case Float(prec, units, min, max, min_alarm, max_alarm):
286-
return builder.aOut(
301+
case Float():
302+
record = builder.aOut(
287303
pv,
288304
always_update=True,
289305
on_update=on_update,
290-
PREC=prec,
291-
EGU=units,
292-
DRVL=min,
293-
DRVH=max,
294-
LOPR=min_alarm,
295-
HOPR=max_alarm,
306+
**datatype_to_epics_fields(attribute.datatype),
296307
**attribute_fields,
297308
)
298309
case String():
299-
return builder.longStringOut(
310+
record = builder.longStringOut(
300311
pv, always_update=True, on_update=on_update, **attribute_fields
301312
)
302313
case _:
303314
raise FastCSException(
304315
f"Unsupported type {type(attribute.datatype)}: {attribute.datatype}"
305316
)
306317

318+
def datatype_updater(datatype: DataType):
319+
for name, value in datatype_to_epics_fields(datatype).items():
320+
record.set_field(name, value)
321+
322+
attribute.add_update_datatype_callback(datatype_updater)
323+
return record
324+
307325

308326
def _create_and_link_command_pvs(pv_prefix: str, mapping: Mapping) -> None:
309327
for single_mapping in mapping.get_controller_mappings():

src/fastcs/datatypes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
AttrCallback = Callable[[T], Awaitable[None]]
1313

1414

15+
@dataclass(frozen=True) # So that we can type hint with dataclass methods
1516
class DataType(Generic[T]):
1617
"""Generic datatype mapping to a python type, with additional metadata."""
1718

tests/backends/epics/test_ioc.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,3 +459,23 @@ def test_long_pv_names_discarded(mocker: MockerFixture):
459459
always_update=True,
460460
on_update=mocker.ANY,
461461
)
462+
463+
464+
def test_update_datatype(mocker: MockerFixture):
465+
builder = mocker.patch("fastcs.backends.epics.ioc.builder")
466+
467+
pv_name = f"{DEVICE}:Attr"
468+
attr = AttrR(Int())
469+
record = _get_input_record(pv_name, attr)
470+
471+
builder.longIn.assert_called_once_with(pv_name, **DEFAULT_SCALAR_FIELD_ARGS)
472+
record.set_field.assert_not_called()
473+
attr.update_datatype(Int(units="m", min=-3))
474+
record.set_field.assert_any_call("EGU", "m")
475+
record.set_field.assert_any_call("DRVL", -3)
476+
477+
with pytest.raises(
478+
ValueError,
479+
match="Attribute datatype must be of type <class 'fastcs.datatypes.Int'>",
480+
):
481+
attr.update_datatype(String()) # type: ignore

0 commit comments

Comments
 (0)