Skip to content

Commit 1602252

Browse files
committed
Redesign attribute callbacks
1 parent 019ae15 commit 1602252

File tree

23 files changed

+222
-227
lines changed

23 files changed

+222
-227
lines changed

docs/conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@
8888
("py:class", "p4p.nt.ndarray.NTNDArray"),
8989
("py:class", "p4p.nt.NTTable"),
9090
# Problems in FastCS itself
91+
("py:class", "T"),
92+
("py:class", "AttrUpdateCallback"),
9193
("py:class", "fastcs.transport.epics.pva.pvi_tree._PviSignalInfo"),
9294
("py:class", "fastcs.logging._logging.LogLevel"),
9395
("py:class", "fastcs.logging._graylog.GraylogEndpoint"),

docs/snippets/static13.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ async def update_voltages(self):
100100
@command()
101101
async def disable_all(self) -> None:
102102
for rc in self._ramp_controllers:
103-
await rc.enabled.process(OnOffEnum.Off)
103+
await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True)
104104
# TODO: The requests all get concatenated and the sim doesn't handle it
105105
await asyncio.sleep(0.1)
106106

src/fastcs/attribute_io.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Any, Generic, cast, get_args
22

33
from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT
4-
from fastcs.attributes import AttrR, AttrRW
4+
from fastcs.attributes import AttrR, AttrW
55
from fastcs.datatypes import T
66
from fastcs.tracer import Tracer
77

@@ -32,7 +32,7 @@ def __init__(self):
3232
async def update(self, attr: AttrR[T, AttributeIORefT]) -> None:
3333
raise NotImplementedError()
3434

35-
async def send(self, attr: AttrRW[T, AttributeIORefT], value: T) -> None:
35+
async def send(self, attr: AttrW[T, AttributeIORefT], value: T) -> None:
3636
raise NotImplementedError()
3737

3838

src/fastcs/attribute_io_ref.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ class AttributeIORef:
2020

2121

2222
AttributeIORefT = TypeVar(
23-
"AttributeIORefT", bound=AttributeIORef, default=AttributeIORef
23+
"AttributeIORefT", bound=AttributeIORef, default=AttributeIORef, covariant=True
2424
)

src/fastcs/attributes.py

Lines changed: 114 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
from __future__ import annotations
22

33
import asyncio
4-
from collections.abc import Callable
5-
from typing import Generic
4+
from collections.abc import Awaitable, Callable
5+
from typing import Any, Generic
66

77
from fastcs.attribute_io_ref import AttributeIORefT
8-
from fastcs.datatypes import (
9-
ATTRIBUTE_TYPES,
10-
AttrSetCallback,
11-
AttrUpdateCallback,
12-
DataType,
13-
T,
14-
)
8+
from fastcs.datatypes import ATTRIBUTE_TYPES, DataType, T
159
from fastcs.tracer import Tracer
1610

1711
ONCE = float("inf")
@@ -97,6 +91,10 @@ def __repr__(self):
9791
return f"{self.__class__.__name__}({self._name}, {self._datatype})"
9892

9993

94+
AttrUpdateCallback = Callable[["AttrR[T, Any]"], Awaitable[None]]
95+
AttrOnSetCallback = Callable[[T], Awaitable[None]]
96+
97+
10098
class AttrR(Attribute[T, AttributeIORefT]):
10199
"""A read-only ``Attribute``."""
102100

@@ -108,42 +106,66 @@ def __init__(
108106
initial_value: T | None = None,
109107
description: str | None = None,
110108
) -> None:
111-
super().__init__(
112-
datatype, # type: ignore
113-
io_ref,
114-
group,
115-
description=description,
116-
)
109+
super().__init__(datatype, io_ref, group, description=description)
117110
self._value: T = (
118111
datatype.initial_value if initial_value is None else initial_value
119112
)
120-
self._on_set_callbacks: list[AttrSetCallback[T]] | None = None
121-
self._on_update_callbacks: list[AttrUpdateCallback] | None = None
113+
self._update_callback: AttrUpdateCallback[T] | None = None
114+
"""Callback to update the value of the attribute from the source"""
115+
self._on_set_callbacks: list[AttrOnSetCallback[T]] | None = None
116+
"""Callbacks to publish changes to the value of the attribute"""
122117

123118
def get(self) -> T:
119+
"""Get the cached value of the attribute."""
124120
return self._value
125121

126122
async def set(self, value: T) -> None:
123+
"""Set the value of the attibute
124+
125+
This sets the cached value of the attribute presented in the API. It should
126+
generally only be called from an IO or a controller that is updating the value
127+
from some underlying source.
128+
129+
To request a change to the setpoint of the attribute, use the ``put`` method,
130+
which will attempt to apply the change to the underlying source.
131+
132+
"""
127133
self.log_event("Attribute set", attribute=self, value=value)
128134

129135
self._value = self._datatype.validate(value)
130136

131137
if self._on_set_callbacks is not None:
132138
await asyncio.gather(*[cb(self._value) for cb in self._on_set_callbacks])
133139

134-
def add_set_callback(self, callback: AttrSetCallback[T]) -> None:
140+
def add_on_set_callback(self, callback: AttrOnSetCallback[T]) -> None:
141+
"""Add a callback to be called when the attribute is set
142+
143+
The callback will be called with the new value when the attribute is set.
144+
145+
"""
135146
if self._on_set_callbacks is None:
136147
self._on_set_callbacks = []
137148
self._on_set_callbacks.append(callback)
138149

139-
def add_update_callback(self, callback: AttrUpdateCallback):
140-
if self._on_update_callbacks is None:
141-
self._on_update_callbacks = []
142-
self._on_update_callbacks.append(callback)
143-
144150
async def update(self):
145-
if self._on_update_callbacks is not None:
146-
await asyncio.gather(*[cb() for cb in self._on_update_callbacks])
151+
"""Update the attribute value via its IO, if it has one"""
152+
if self._update_callback is not None:
153+
await self._update_callback(self)
154+
155+
def set_update_callback(self, callback: AttrUpdateCallback[T]):
156+
"""Set the callback to update the value of the attribute from the source
157+
158+
The callback will be called with the attribute when it needs updating.
159+
160+
"""
161+
if self._update_callback is not None:
162+
raise RuntimeError("Attribute already has an update callback")
163+
164+
self._update_callback = callback
165+
166+
167+
AttrOnPutCallback = Callable[["AttrW[T, Any]", T], Awaitable[None]]
168+
AttrSyncSetpointCallback = Callable[[T], Awaitable[None]]
147169

148170

149171
class AttrW(Attribute[T, AttributeIORefT]):
@@ -162,41 +184,83 @@ def __init__(
162184
group,
163185
description=description,
164186
)
165-
self._process_callbacks: list[AttrSetCallback[T]] | None = None
166-
self._write_display_callbacks: list[AttrSetCallback[T]] | None = None
187+
self._on_put_callback: AttrOnPutCallback[T] | None = None
188+
"""Callback to action the put of a new value to attribute"""
189+
self._sync_setpoint_callbacks: list[AttrSyncSetpointCallback[T]] = []
190+
"""Callbacks to publish changes to the setpoint of the attribute"""
191+
192+
async def put(self, setpoint: T, sync_setpoint: bool = False) -> None:
193+
"""Set the setpoint of the attribute
194+
195+
This should be called by clients to the attribute such as transports to apply a
196+
change to the attribute. The ``_on_put_callback`` will be called with this new
197+
setpoint, which may or may not take effect depending on the validity of the new
198+
value. For example, if the attribute has an IO to some device, the value might
199+
be rejected.
200+
201+
To directly change the value of the attribute, for example from an update loop
202+
that has read a new value from some underlying source, call the ``set`` method.
203+
204+
"""
205+
setpoint = self._datatype.validate(setpoint)
206+
if self._on_put_callback is not None:
207+
await self._on_put_callback(self, setpoint)
208+
209+
if sync_setpoint:
210+
await self._call_sync_setpoint_callbacks(setpoint)
211+
212+
async def _call_sync_setpoint_callbacks(self, setpoint: T) -> None:
213+
if self._sync_setpoint_callbacks:
214+
await asyncio.gather(
215+
*[cb(setpoint) for cb in self._sync_setpoint_callbacks]
216+
)
167217

168-
async def process(self, value: T) -> None:
169-
await self.process_without_display_update(value)
170-
await self.update_display_without_process(value)
218+
def set_on_put_callback(self, callback: AttrOnPutCallback[T]) -> None:
219+
"""Set the callback to call when the setpoint is changed
171220
172-
async def process_without_display_update(self, value: T) -> None:
173-
value = self._datatype.validate(value)
174-
if self._process_callbacks:
175-
await asyncio.gather(*[cb(value) for cb in self._process_callbacks])
221+
The callback will be called with the attribute and the new setpoint.
176222
177-
async def update_display_without_process(self, value: T) -> None:
178-
value = self._datatype.validate(value)
179-
if self._write_display_callbacks:
180-
await asyncio.gather(*[cb(value) for cb in self._write_display_callbacks])
223+
"""
224+
if self._on_put_callback is not None:
225+
raise RuntimeError("Attribute already has an on put callback")
181226

182-
def add_process_callback(self, callback: AttrSetCallback[T]) -> None:
183-
if self._process_callbacks is None:
184-
self._process_callbacks = []
185-
self._process_callbacks.append(callback)
227+
self._on_put_callback = callback
186228

187-
def has_process_callback(self) -> bool:
188-
return bool(self._process_callbacks)
229+
def add_sync_setpoint_callback(self, callback: AttrSyncSetpointCallback[T]) -> None:
230+
"""Add a callback to publish changes to the setpoint of the attribute
189231
190-
def add_write_display_callback(self, callback: AttrSetCallback[T]) -> None:
191-
if self._write_display_callbacks is None:
192-
self._write_display_callbacks = []
193-
self._write_display_callbacks.append(callback)
232+
The callback will be called with the new setpoint.
233+
234+
"""
235+
self._sync_setpoint_callbacks.append(callback)
194236

195237

196238
class AttrRW(AttrR[T, AttributeIORefT], AttrW[T, AttributeIORefT]):
197239
"""A read-write ``Attribute``."""
198240

199-
async def process(self, value: T) -> None:
241+
def __init__(
242+
self,
243+
datatype: DataType[T],
244+
io_ref: AttributeIORefT | None = None,
245+
group: str | None = None,
246+
initial_value: T | None = None,
247+
description: str | None = None,
248+
):
249+
super().__init__(datatype, io_ref, group, initial_value, description)
250+
251+
self._setpoint_initialised = False
252+
253+
if io_ref is None:
254+
self.set_on_put_callback(self._internal_put)
255+
256+
async def _internal_put(self, attr: AttrW[T, AttributeIORefT], value: T):
257+
"""Set value directly when Attribute has no IO"""
258+
assert attr is self
200259
await self.set(value)
201260

202-
await super().process(value) # type: ignore
261+
async def set(self, value: T):
262+
await super().set(value)
263+
264+
if not self._setpoint_initialised:
265+
await self._call_sync_setpoint_callbacks(value)
266+
self._setpoint_initialised = True

src/fastcs/controller.py

Lines changed: 16 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
from fastcs.attribute_io import AttributeIO
99
from fastcs.attribute_io_ref import AttributeIORefT
10-
from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
10+
from fastcs.attributes import (
11+
Attribute,
12+
AttrR,
13+
AttrW,
14+
)
1115
from fastcs.datatypes import T
1216
from fastcs.tracer import Tracer
1317

@@ -58,39 +62,20 @@ async def attribute_initialise(self) -> None:
5862
def _add_io_callbacks(self):
5963
for attr in self.attributes.values():
6064
ref = attr.io_ref if attr.has_io_ref() else None
65+
if ref is None:
66+
continue
67+
6168
io = self._attribute_ref_io_map.get(type(ref))
69+
if io is None:
70+
raise ValueError(
71+
f"{self.__class__.__name__} does not have an AttributeIO "
72+
f"to handle {attr.io_ref.__class__.__name__}"
73+
)
74+
6275
if isinstance(attr, AttrW):
63-
attr.add_process_callback(self._create_send_callback(io, attr, ref))
76+
attr.set_on_put_callback(io.send)
6477
if isinstance(attr, AttrR):
65-
attr.add_update_callback(self._create_update_callback(io, attr, ref))
66-
67-
def _create_send_callback(self, io, attr, ref):
68-
if ref is None:
69-
70-
async def send_callback(value):
71-
await attr.update_display_without_process(value)
72-
if isinstance(attr, AttrRW):
73-
await attr.set(value)
74-
else:
75-
76-
async def send_callback(value):
77-
await io.send(attr, value)
78-
79-
return send_callback
80-
81-
def _create_update_callback(self, io, attr, ref):
82-
if ref is None:
83-
84-
async def error_callback():
85-
raise RuntimeError("Can't call update on Attributes without an io_ref")
86-
87-
return error_callback
88-
else:
89-
90-
async def update_callback():
91-
await io.update(attr)
92-
93-
return update_callback
78+
attr.set_update_callback(io.update)
9479

9580
@property
9681
def path(self) -> list[str]:
@@ -145,14 +130,6 @@ def _validate_io(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]):
145130
f"More than one AttributeIO class handles {ref_type.__name__}"
146131
)
147132

148-
for attr in self.attributes.values():
149-
if not attr.has_io_ref():
150-
continue
151-
assert type(attr.io_ref) in self._attribute_ref_io_map, (
152-
f"{self.__class__.__name__} does not have an AttributeIO to handle "
153-
f"{attr.io_ref.__class__.__name__}"
154-
)
155-
156133
def add_attribute(self, name, attribute: Attribute):
157134
if name in self.attributes and attribute is not self.attributes[name]:
158135
raise ValueError(

src/fastcs/datatypes.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import enum
44
from abc import abstractmethod
5-
from collections.abc import Awaitable, Callable
65
from dataclasses import dataclass
76
from functools import cached_property
87
from typing import Any, Generic, TypeVar
@@ -24,10 +23,6 @@
2423
ATTRIBUTE_TYPES: tuple[type] = T.__constraints__ # type: ignore
2524

2625

27-
AttrSetCallback = Callable[[T], Awaitable[None]]
28-
AttrUpdateCallback = Callable[[], Awaitable[None]]
29-
30-
3126
@dataclass(frozen=True)
3227
class DataType(Generic[T]):
3328
"""Generic datatype mapping to a python type, with additional metadata."""

src/fastcs/demo/controllers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def __init__(self, settings: TemperatureControllerSettings) -> None:
8787
@command()
8888
async def cancel_all(self) -> None:
8989
for rc in self._ramp_controllers:
90-
await rc.enabled.process(OnOffEnum.Off)
90+
await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True)
9191
# TODO: The requests all get concatenated and the sim doesn't handle it
9292
await asyncio.sleep(0.1)
9393

0 commit comments

Comments
 (0)