ophyd-async 0.7.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. ophyd_async/_version.py +2 -2
  2. ophyd_async/core/__init__.py +34 -9
  3. ophyd_async/core/_detector.py +5 -10
  4. ophyd_async/core/_device.py +170 -68
  5. ophyd_async/core/_device_filler.py +269 -0
  6. ophyd_async/core/_device_save_loader.py +6 -7
  7. ophyd_async/core/_mock_signal_backend.py +35 -40
  8. ophyd_async/core/_mock_signal_utils.py +25 -16
  9. ophyd_async/core/_protocol.py +28 -8
  10. ophyd_async/core/_readable.py +133 -134
  11. ophyd_async/core/_signal.py +219 -163
  12. ophyd_async/core/_signal_backend.py +131 -64
  13. ophyd_async/core/_soft_signal_backend.py +131 -194
  14. ophyd_async/core/_status.py +22 -6
  15. ophyd_async/core/_table.py +102 -100
  16. ophyd_async/core/_utils.py +143 -32
  17. ophyd_async/epics/adaravis/_aravis_controller.py +2 -2
  18. ophyd_async/epics/adaravis/_aravis_io.py +8 -6
  19. ophyd_async/epics/adcore/_core_io.py +5 -7
  20. ophyd_async/epics/adcore/_core_logic.py +3 -1
  21. ophyd_async/epics/adcore/_hdf_writer.py +2 -2
  22. ophyd_async/epics/adcore/_single_trigger.py +6 -10
  23. ophyd_async/epics/adcore/_utils.py +15 -10
  24. ophyd_async/epics/adkinetix/__init__.py +2 -1
  25. ophyd_async/epics/adkinetix/_kinetix_controller.py +6 -3
  26. ophyd_async/epics/adkinetix/_kinetix_io.py +4 -5
  27. ophyd_async/epics/adpilatus/_pilatus_controller.py +2 -2
  28. ophyd_async/epics/adpilatus/_pilatus_io.py +3 -4
  29. ophyd_async/epics/adsimdetector/_sim_controller.py +2 -2
  30. ophyd_async/epics/advimba/__init__.py +4 -1
  31. ophyd_async/epics/advimba/_vimba_controller.py +6 -3
  32. ophyd_async/epics/advimba/_vimba_io.py +8 -9
  33. ophyd_async/epics/core/__init__.py +26 -0
  34. ophyd_async/epics/core/_aioca.py +323 -0
  35. ophyd_async/epics/core/_epics_connector.py +53 -0
  36. ophyd_async/epics/core/_epics_device.py +13 -0
  37. ophyd_async/epics/core/_p4p.py +383 -0
  38. ophyd_async/epics/core/_pvi_connector.py +91 -0
  39. ophyd_async/epics/core/_signal.py +171 -0
  40. ophyd_async/epics/core/_util.py +61 -0
  41. ophyd_async/epics/demo/_mover.py +4 -5
  42. ophyd_async/epics/demo/_sensor.py +14 -13
  43. ophyd_async/epics/eiger/_eiger.py +1 -2
  44. ophyd_async/epics/eiger/_eiger_controller.py +7 -2
  45. ophyd_async/epics/eiger/_eiger_io.py +3 -5
  46. ophyd_async/epics/eiger/_odin_io.py +5 -5
  47. ophyd_async/epics/motor.py +4 -5
  48. ophyd_async/epics/signal.py +11 -0
  49. ophyd_async/epics/testing/__init__.py +24 -0
  50. ophyd_async/epics/testing/_example_ioc.py +105 -0
  51. ophyd_async/epics/testing/_utils.py +78 -0
  52. ophyd_async/epics/testing/test_records.db +152 -0
  53. ophyd_async/epics/testing/test_records_pva.db +177 -0
  54. ophyd_async/fastcs/core.py +9 -0
  55. ophyd_async/fastcs/panda/__init__.py +4 -4
  56. ophyd_async/fastcs/panda/_block.py +18 -13
  57. ophyd_async/fastcs/panda/_control.py +3 -5
  58. ophyd_async/fastcs/panda/_hdf_panda.py +5 -19
  59. ophyd_async/fastcs/panda/_table.py +30 -52
  60. ophyd_async/fastcs/panda/_trigger.py +8 -8
  61. ophyd_async/fastcs/panda/_writer.py +2 -5
  62. ophyd_async/plan_stubs/_ensure_connected.py +20 -13
  63. ophyd_async/plan_stubs/_fly.py +2 -2
  64. ophyd_async/plan_stubs/_nd_attributes.py +5 -4
  65. ophyd_async/py.typed +0 -0
  66. ophyd_async/sim/demo/_pattern_detector/_pattern_detector_controller.py +1 -2
  67. ophyd_async/sim/demo/_sim_motor.py +3 -4
  68. ophyd_async/tango/__init__.py +0 -45
  69. ophyd_async/tango/{signal → core}/__init__.py +9 -6
  70. ophyd_async/tango/core/_base_device.py +132 -0
  71. ophyd_async/tango/{signal → core}/_signal.py +42 -53
  72. ophyd_async/tango/{base_devices → core}/_tango_readable.py +3 -4
  73. ophyd_async/tango/{signal → core}/_tango_transport.py +38 -40
  74. ophyd_async/tango/demo/_counter.py +12 -23
  75. ophyd_async/tango/demo/_mover.py +13 -13
  76. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0.dist-info}/METADATA +52 -55
  77. ophyd_async-0.8.0.dist-info/RECORD +116 -0
  78. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0.dist-info}/WHEEL +1 -1
  79. ophyd_async/epics/pvi/__init__.py +0 -3
  80. ophyd_async/epics/pvi/_pvi.py +0 -338
  81. ophyd_async/epics/signal/__init__.py +0 -21
  82. ophyd_async/epics/signal/_aioca.py +0 -378
  83. ophyd_async/epics/signal/_common.py +0 -57
  84. ophyd_async/epics/signal/_epics_transport.py +0 -34
  85. ophyd_async/epics/signal/_p4p.py +0 -518
  86. ophyd_async/epics/signal/_signal.py +0 -114
  87. ophyd_async/tango/base_devices/__init__.py +0 -4
  88. ophyd_async/tango/base_devices/_base_device.py +0 -225
  89. ophyd_async-0.7.0.dist-info/RECORD +0 -108
  90. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0.dist-info}/LICENSE +0 -0
  91. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0.dist-info}/entry_points.txt +0 -0
  92. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0.dist-info}/top_level.txt +0 -0
@@ -2,128 +2,107 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import functools
5
- from collections.abc import AsyncGenerator, Callable, Mapping
6
- from typing import Any, Generic, TypeVar, cast
5
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
6
+ from typing import Any, Generic, cast
7
7
 
8
8
  from bluesky.protocols import (
9
9
  Locatable,
10
10
  Location,
11
11
  Movable,
12
- Reading,
13
12
  Status,
14
13
  Subscribable,
15
14
  )
16
15
  from event_model import DataKey
17
16
 
18
- from ._device import Device
17
+ from ._device import Device, DeviceConnector
19
18
  from ._mock_signal_backend import MockSignalBackend
20
- from ._protocol import AsyncConfigurable, AsyncReadable, AsyncStageable
21
- from ._signal_backend import SignalBackend
22
- from ._soft_signal_backend import SignalMetadata, SoftSignalBackend
23
- from ._status import AsyncStatus
24
- from ._utils import CALCULATE_TIMEOUT, DEFAULT_TIMEOUT, CalculatableTimeout, Callback, T
19
+ from ._protocol import (
20
+ AsyncConfigurable,
21
+ AsyncReadable,
22
+ AsyncStageable,
23
+ Reading,
24
+ )
25
+ from ._signal_backend import (
26
+ SignalBackend,
27
+ SignalDatatypeT,
28
+ SignalDatatypeV,
29
+ )
30
+ from ._soft_signal_backend import SoftSignalBackend
31
+ from ._status import AsyncStatus, completed_status
32
+ from ._utils import (
33
+ CALCULATE_TIMEOUT,
34
+ DEFAULT_TIMEOUT,
35
+ CalculatableTimeout,
36
+ Callback,
37
+ LazyMock,
38
+ T,
39
+ )
40
+
25
41
 
26
- S = TypeVar("S")
42
+ async def _wait_for(coro: Awaitable[T], timeout: float | None, source: str) -> T:
43
+ try:
44
+ return await asyncio.wait_for(coro, timeout)
45
+ except asyncio.TimeoutError as e:
46
+ raise asyncio.TimeoutError(source) from e
27
47
 
28
48
 
29
49
  def _add_timeout(func):
30
50
  @functools.wraps(func)
31
51
  async def wrapper(self: Signal, *args, **kwargs):
32
- return await asyncio.wait_for(func(self, *args, **kwargs), self._timeout)
52
+ return await _wait_for(func(self, *args, **kwargs), self._timeout, self.source)
33
53
 
34
54
  return wrapper
35
55
 
36
56
 
37
- def _fail(*args, **kwargs):
38
- raise RuntimeError("Signal has not been supplied a backend yet")
57
+ class SignalConnector(DeviceConnector):
58
+ def __init__(self, backend: SignalBackend):
59
+ self.backend = self._init_backend = backend
39
60
 
61
+ async def connect_mock(self, device: Device, mock: LazyMock):
62
+ self.backend = MockSignalBackend(self._init_backend, mock)
40
63
 
41
- class DisconnectedBackend(SignalBackend):
42
- source = connect = put = get_datakey = get_reading = get_value = get_setpoint = (
43
- set_callback
44
- ) = _fail
64
+ async def connect_real(self, device: Device, timeout: float, force_reconnect: bool):
65
+ self.backend = self._init_backend
66
+ device.log.debug(f"Connecting to {self.backend.source(device.name, read=True)}")
67
+ await self.backend.connect(timeout)
45
68
 
46
69
 
47
- DISCONNECTED_BACKEND = DisconnectedBackend()
70
+ class _ChildrenNotAllowed(dict[str, Device]):
71
+ def __setitem__(self, key: str, value: Device) -> None:
72
+ raise AttributeError(
73
+ f"Cannot add Device or Signal child {key}={value} of Signal, "
74
+ "make a subclass of Device instead"
75
+ )
48
76
 
49
77
 
50
- class Signal(Device, Generic[T]):
78
+ class Signal(Device, Generic[SignalDatatypeT]):
51
79
  """A Device with the concept of a value, with R, RW, W and X flavours"""
52
80
 
81
+ _connector: SignalConnector
82
+ _child_devices = _ChildrenNotAllowed() # type: ignore
83
+
53
84
  def __init__(
54
85
  self,
55
- backend: SignalBackend[T] = DISCONNECTED_BACKEND,
86
+ backend: SignalBackend[SignalDatatypeT],
56
87
  timeout: float | None = DEFAULT_TIMEOUT,
57
88
  name: str = "",
58
89
  ) -> None:
90
+ super().__init__(name=name, connector=SignalConnector(backend))
59
91
  self._timeout = timeout
60
- self._backend = backend
61
- super().__init__(name)
62
-
63
- async def connect(
64
- self,
65
- mock=False,
66
- timeout=DEFAULT_TIMEOUT,
67
- force_reconnect: bool = False,
68
- backend: SignalBackend[T] | None = None,
69
- ):
70
- if backend:
71
- if (
72
- self._backend is not DISCONNECTED_BACKEND
73
- and backend is not self._backend
74
- ):
75
- raise ValueError("Backend at connection different from previous one.")
76
-
77
- self._backend = backend
78
- if (
79
- self._previous_connect_was_mock is not None
80
- and self._previous_connect_was_mock != mock
81
- ):
82
- raise RuntimeError(
83
- f"`connect(mock={mock})` called on a `Signal` where the previous "
84
- f"connect was `mock={self._previous_connect_was_mock}`. Changing mock "
85
- "value between connects is not permitted."
86
- )
87
- self._previous_connect_was_mock = mock
88
-
89
- if mock and not issubclass(type(self._backend), MockSignalBackend):
90
- # Using a soft backend, look to the initial value
91
- self._backend = MockSignalBackend(initial_backend=self._backend)
92
-
93
- if self._backend is None:
94
- raise RuntimeError("`connect` called on signal without backend")
95
-
96
- can_use_previous_connection: bool = self._connect_task is not None and not (
97
- self._connect_task.done() and self._connect_task.exception()
98
- )
99
-
100
- if force_reconnect or not can_use_previous_connection:
101
- self.log.debug(f"Connecting to {self.source}")
102
- self._connect_task = asyncio.create_task(
103
- self._backend.connect(timeout=timeout)
104
- )
105
- else:
106
- self.log.debug(f"Reusing previous connection to {self.source}")
107
- assert (
108
- self._connect_task
109
- ), "this assert is for type analysis and will never fail"
110
- await self._connect_task
111
92
 
112
93
  @property
113
94
  def source(self) -> str:
114
95
  """Like ca://PV_PREFIX:SIGNAL, or "" if not set"""
115
- return self._backend.source(self.name)
96
+ return self._connector.backend.source(self.name, read=True)
116
97
 
117
98
 
118
- class _SignalCache(Generic[T]):
119
- def __init__(self, backend: SignalBackend[T], signal: Signal):
99
+ class _SignalCache(Generic[SignalDatatypeT]):
100
+ def __init__(self, backend: SignalBackend[SignalDatatypeT], signal: Signal):
120
101
  self._signal = signal
121
102
  self._staged = False
122
103
  self._listeners: dict[Callback, bool] = {}
123
104
  self._valid = asyncio.Event()
124
- self._reading: Reading | None = None
125
- self._value: T | None = None
126
-
105
+ self._reading: Reading[SignalDatatypeT] | None = None
127
106
  self.backend = backend
128
107
  signal.log.debug(f"Making subscription on source {signal.source}")
129
108
  backend.set_callback(self._callback)
@@ -132,30 +111,33 @@ class _SignalCache(Generic[T]):
132
111
  self.backend.set_callback(None)
133
112
  self._signal.log.debug(f"Closing subscription on source {self._signal.source}")
134
113
 
135
- async def get_reading(self) -> Reading:
114
+ async def get_reading(self) -> Reading[SignalDatatypeT]:
136
115
  await self._valid.wait()
137
116
  assert self._reading is not None, "Monitor not working"
138
117
  return self._reading
139
118
 
140
- async def get_value(self) -> T:
141
- await self._valid.wait()
142
- assert self._value is not None, "Monitor not working"
143
- return self._value
119
+ async def get_value(self) -> SignalDatatypeT:
120
+ reading = await self.get_reading()
121
+ return reading["value"]
144
122
 
145
- def _callback(self, reading: Reading, value: T):
123
+ def _callback(self, reading: Reading[SignalDatatypeT]):
146
124
  self._signal.log.debug(
147
- f"Updated subscription: reading of source {self._signal.source} changed"
125
+ f"Updated subscription: reading of source {self._signal.source} changed "
148
126
  f"from {self._reading} to {reading}"
149
127
  )
150
128
  self._reading = reading
151
- self._value = value
152
129
  self._valid.set()
153
130
  for function, want_value in self._listeners.items():
154
131
  self._notify(function, want_value)
155
132
 
156
- def _notify(self, function: Callback, want_value: bool):
133
+ def _notify(
134
+ self,
135
+ function: Callback[dict[str, Reading[SignalDatatypeT]] | SignalDatatypeT],
136
+ want_value: bool,
137
+ ):
138
+ assert self._reading, "Monitor not working"
157
139
  if want_value:
158
- function(self._value)
140
+ function(self._reading["value"])
159
141
  else:
160
142
  function({self._signal.name: self._reading})
161
143
 
@@ -173,12 +155,14 @@ class _SignalCache(Generic[T]):
173
155
  return self._staged or bool(self._listeners)
174
156
 
175
157
 
176
- class SignalR(Signal[T], AsyncReadable, AsyncStageable, Subscribable):
158
+ class SignalR(Signal[SignalDatatypeT], AsyncReadable, AsyncStageable, Subscribable):
177
159
  """Signal that can be read from and monitored"""
178
160
 
179
161
  _cache: _SignalCache | None = None
180
162
 
181
- def _backend_or_cache(self, cached: bool | None) -> _SignalCache | SignalBackend:
163
+ def _backend_or_cache(
164
+ self, cached: bool | None = None
165
+ ) -> _SignalCache | SignalBackend:
182
166
  # If cached is None then calculate it based on whether we already have a cache
183
167
  if cached is None:
184
168
  cached = self._cache is not None
@@ -186,11 +170,11 @@ class SignalR(Signal[T], AsyncReadable, AsyncStageable, Subscribable):
186
170
  assert self._cache, f"{self.source} not being monitored"
187
171
  return self._cache
188
172
  else:
189
- return self._backend
173
+ return self._connector.backend
190
174
 
191
175
  def _get_cache(self) -> _SignalCache:
192
176
  if not self._cache:
193
- self._cache = _SignalCache(self._backend, self)
177
+ self._cache = _SignalCache(self._connector.backend, self)
194
178
  return self._cache
195
179
 
196
180
  def _del_cache(self, needed: bool):
@@ -206,16 +190,16 @@ class SignalR(Signal[T], AsyncReadable, AsyncStageable, Subscribable):
206
190
  @_add_timeout
207
191
  async def describe(self) -> dict[str, DataKey]:
208
192
  """Return a single item dict with the descriptor in it"""
209
- return {self.name: await self._backend.get_datakey(self.source)}
193
+ return {self.name: await self._connector.backend.get_datakey(self.source)}
210
194
 
211
195
  @_add_timeout
212
- async def get_value(self, cached: bool | None = None) -> T:
196
+ async def get_value(self, cached: bool | None = None) -> SignalDatatypeT:
213
197
  """The current value"""
214
198
  value = await self._backend_or_cache(cached).get_value()
215
199
  self.log.debug(f"get_value() on source {self.source} returned {value}")
216
200
  return value
217
201
 
218
- def subscribe_value(self, function: Callback[T]):
202
+ def subscribe_value(self, function: Callback[SignalDatatypeT]):
219
203
  """Subscribe to updates in value of a device"""
220
204
  self._get_cache().subscribe(function, want_value=True)
221
205
 
@@ -238,84 +222,82 @@ class SignalR(Signal[T], AsyncReadable, AsyncStageable, Subscribable):
238
222
  self._del_cache(self._get_cache().set_staged(False))
239
223
 
240
224
 
241
- class SignalW(Signal[T], Movable):
225
+ class SignalW(Signal[SignalDatatypeT], Movable):
242
226
  """Signal that can be set"""
243
227
 
244
- def set(
245
- self, value: T, wait=True, timeout: CalculatableTimeout = CALCULATE_TIMEOUT
246
- ) -> AsyncStatus:
228
+ @AsyncStatus.wrap
229
+ async def set(
230
+ self,
231
+ value: SignalDatatypeT,
232
+ wait=True,
233
+ timeout: CalculatableTimeout = CALCULATE_TIMEOUT,
234
+ ) -> None:
247
235
  """Set the value and return a status saying when it's done"""
248
- if timeout is CALCULATE_TIMEOUT:
236
+ if timeout == CALCULATE_TIMEOUT:
249
237
  timeout = self._timeout
238
+ source = self._connector.backend.source(self.name, read=False)
239
+ self.log.debug(f"Putting value {value} to backend at source {source}")
240
+ await _wait_for(self._connector.backend.put(value, wait=wait), timeout, source)
241
+ self.log.debug(f"Successfully put value {value} to backend at source {source}")
250
242
 
251
- async def do_set():
252
- self.log.debug(f"Putting value {value} to backend at source {self.source}")
253
- await self._backend.put(value, wait=wait, timeout=timeout)
254
- self.log.debug(
255
- f"Successfully put value {value} to backend at source {self.source}"
256
- )
257
-
258
- return AsyncStatus(do_set())
259
243
 
260
-
261
- class SignalRW(SignalR[T], SignalW[T], Locatable):
244
+ class SignalRW(SignalR[SignalDatatypeT], SignalW[SignalDatatypeT], Locatable):
262
245
  """Signal that can be both read and set"""
263
246
 
247
+ @_add_timeout
264
248
  async def locate(self) -> Location:
265
- location: Location = {
266
- "setpoint": await self._backend.get_setpoint(),
267
- "readback": await self.get_value(),
268
- }
269
- return location
249
+ """Return the setpoint and readback."""
250
+ setpoint, readback = await asyncio.gather(
251
+ self._connector.backend.get_setpoint(), self._backend_or_cache().get_value()
252
+ )
253
+ return Location(setpoint=setpoint, readback=readback)
270
254
 
271
255
 
272
256
  class SignalX(Signal):
273
257
  """Signal that puts the default value"""
274
258
 
275
- def trigger(
259
+ @AsyncStatus.wrap
260
+ async def trigger(
276
261
  self, wait=True, timeout: CalculatableTimeout = CALCULATE_TIMEOUT
277
- ) -> AsyncStatus:
262
+ ) -> None:
278
263
  """Trigger the action and return a status saying when it's done"""
279
- if timeout is CALCULATE_TIMEOUT:
264
+ if timeout == CALCULATE_TIMEOUT:
280
265
  timeout = self._timeout
281
- coro = self._backend.put(None, wait=wait, timeout=timeout)
282
- return AsyncStatus(coro)
266
+ source = self._connector.backend.source(self.name, read=False)
267
+ self.log.debug(f"Putting default value to backend at source {source}")
268
+ await _wait_for(self._connector.backend.put(None, wait=wait), timeout, source)
269
+ self.log.debug(f"Successfully put default value to backend at source {source}")
283
270
 
284
271
 
285
272
  def soft_signal_rw(
286
- datatype: type[T] | None = None,
287
- initial_value: T | None = None,
273
+ datatype: type[SignalDatatypeT],
274
+ initial_value: SignalDatatypeT | None = None,
288
275
  name: str = "",
289
276
  units: str | None = None,
290
277
  precision: int | None = None,
291
- ) -> SignalRW[T]:
278
+ ) -> SignalRW[SignalDatatypeT]:
292
279
  """Creates a read-writable Signal with a SoftSignalBackend.
293
280
  May pass metadata, which are propagated into describe.
294
281
  """
295
- metadata = SignalMetadata(units=units, precision=precision)
296
- signal = SignalRW(
297
- SoftSignalBackend(datatype, initial_value, metadata=metadata),
298
- name=name,
299
- )
282
+ backend = SoftSignalBackend(datatype, initial_value, units, precision)
283
+ signal = SignalRW(backend=backend, name=name)
300
284
  return signal
301
285
 
302
286
 
303
287
  def soft_signal_r_and_setter(
304
- datatype: type[T] | None = None,
305
- initial_value: T | None = None,
288
+ datatype: type[SignalDatatypeT],
289
+ initial_value: SignalDatatypeT | None = None,
306
290
  name: str = "",
307
291
  units: str | None = None,
308
292
  precision: int | None = None,
309
- ) -> tuple[SignalR[T], Callable[[T], None]]:
293
+ ) -> tuple[SignalR[SignalDatatypeT], Callable[[SignalDatatypeT], None]]:
310
294
  """Returns a tuple of a read-only Signal and a callable through
311
295
  which the signal can be internally modified within the device.
312
296
  May pass metadata, which are propagated into describe.
313
297
  Use soft_signal_rw if you want a device that is externally modifiable
314
298
  """
315
- metadata = SignalMetadata(units=units, precision=precision)
316
- backend = SoftSignalBackend(datatype, initial_value, metadata=metadata)
317
- signal = SignalR(backend, name=name)
318
-
299
+ backend = SoftSignalBackend(datatype, initial_value, units, precision)
300
+ signal = SignalR(backend=backend, name=name)
319
301
  return (signal, backend.set_value)
320
302
 
321
303
 
@@ -330,7 +312,7 @@ def _generate_assert_error_msg(name: str, expected_result, actual_result) -> str
330
312
  )
331
313
 
332
314
 
333
- async def assert_value(signal: SignalR[T], value: Any) -> None:
315
+ async def assert_value(signal: SignalR[SignalDatatypeT], value: Any) -> None:
334
316
  """Assert a signal's value and compare it an expected signal.
335
317
 
336
318
  Parameters
@@ -440,8 +422,10 @@ def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int):
440
422
 
441
423
 
442
424
  async def observe_value(
443
- signal: SignalR[T], timeout: float | None = None, done_status: Status | None = None
444
- ) -> AsyncGenerator[T, None]:
425
+ signal: SignalR[SignalDatatypeT],
426
+ timeout: float | None = None,
427
+ done_status: Status | None = None,
428
+ ) -> AsyncGenerator[SignalDatatypeT, None]:
445
429
  """Subscribe to the value of a signal so it can be iterated from.
446
430
 
447
431
  Parameters
@@ -464,7 +448,44 @@ async def observe_value(
464
448
  do_something_with(value)
465
449
  """
466
450
 
467
- q: asyncio.Queue[T | Status] = asyncio.Queue()
451
+ async for _, value in observe_signals_value(
452
+ signal, timeout=timeout, done_status=done_status
453
+ ):
454
+ yield value
455
+
456
+
457
+ async def observe_signals_value(
458
+ *signals: SignalR[SignalDatatypeT],
459
+ timeout: float | None = None,
460
+ done_status: Status | None = None,
461
+ ) -> AsyncGenerator[tuple[SignalR[SignalDatatypeT], SignalDatatypeT], None]:
462
+ """Subscribe to the value of a signal so it can be iterated from.
463
+
464
+ Parameters
465
+ ----------
466
+ signals:
467
+ Call subscribe_value on all the signals at the start, and clear_sub on it at the
468
+ end
469
+ timeout:
470
+ If given, how long to wait for each updated value in seconds. If an update
471
+ is not produced in this time then raise asyncio.TimeoutError
472
+ done_status:
473
+ If this status is complete, stop observing and make the iterator return.
474
+ If it raises an exception then this exception will be raised by the iterator.
475
+
476
+ Notes
477
+ -----
478
+ Example usage::
479
+
480
+ async for signal,value in observe_signals_values(sig1,sig2,..):
481
+ if signal is sig1:
482
+ do_something_with(value)
483
+ elif signal is sig2:
484
+ do_something_else_with(value)
485
+ """
486
+ q: asyncio.Queue[tuple[SignalR[SignalDatatypeT], SignalDatatypeT] | Status] = (
487
+ asyncio.Queue()
488
+ )
468
489
  if timeout is None:
469
490
  get_value = q.get
470
491
  else:
@@ -472,12 +493,23 @@ async def observe_value(
472
493
  async def get_value():
473
494
  return await asyncio.wait_for(q.get(), timeout)
474
495
 
496
+ cbs: dict[SignalR, Callback] = {}
497
+ for signal in signals:
498
+
499
+ def queue_value(value: SignalDatatypeT, signal=signal):
500
+ q.put_nowait((signal, value))
501
+
502
+ cbs[signal] = queue_value
503
+ signal.subscribe_value(queue_value)
504
+
475
505
  if done_status is not None:
476
506
  done_status.add_callback(q.put_nowait)
477
507
 
478
- signal.subscribe_value(q.put_nowait)
479
508
  try:
480
509
  while True:
510
+ # yield here in case something else is filling the queue
511
+ # like in test_observe_value_times_out_with_no_external_task()
512
+ await asyncio.sleep(0)
481
513
  item = await get_value()
482
514
  if done_status and item is done_status:
483
515
  if exc := done_status.exception():
@@ -485,24 +517,27 @@ async def observe_value(
485
517
  else:
486
518
  break
487
519
  else:
488
- yield cast(T, item)
520
+ yield cast(tuple[SignalR[SignalDatatypeT], SignalDatatypeT], item)
489
521
  finally:
490
- signal.clear_sub(q.put_nowait)
522
+ for signal, cb in cbs.items():
523
+ signal.clear_sub(cb)
491
524
 
492
525
 
493
- class _ValueChecker(Generic[T]):
494
- def __init__(self, matcher: Callable[[T], bool], matcher_name: str):
495
- self._last_value: T | None = None
526
+ class _ValueChecker(Generic[SignalDatatypeT]):
527
+ def __init__(self, matcher: Callable[[SignalDatatypeT], bool], matcher_name: str):
528
+ self._last_value: SignalDatatypeT | None = None
496
529
  self._matcher = matcher
497
530
  self._matcher_name = matcher_name
498
531
 
499
- async def _wait_for_value(self, signal: SignalR[T]):
532
+ async def _wait_for_value(self, signal: SignalR[SignalDatatypeT]):
500
533
  async for value in observe_value(signal):
501
534
  self._last_value = value
502
535
  if self._matcher(value):
503
536
  return
504
537
 
505
- async def wait_for_value(self, signal: SignalR[T], timeout: float | None):
538
+ async def wait_for_value(
539
+ self, signal: SignalR[SignalDatatypeT], timeout: float | None
540
+ ):
506
541
  try:
507
542
  await asyncio.wait_for(self._wait_for_value(signal), timeout)
508
543
  except asyncio.TimeoutError as e:
@@ -513,8 +548,8 @@ class _ValueChecker(Generic[T]):
513
548
 
514
549
 
515
550
  async def wait_for_value(
516
- signal: SignalR[T],
517
- match: T | Callable[[T], bool],
551
+ signal: SignalR[SignalDatatypeT],
552
+ match: SignalDatatypeT | Callable[[SignalDatatypeT], bool],
518
553
  timeout: float | None,
519
554
  ):
520
555
  """Wait for a signal to have a matching value.
@@ -548,17 +583,18 @@ async def wait_for_value(
548
583
 
549
584
 
550
585
  async def set_and_wait_for_other_value(
551
- set_signal: SignalW[T],
552
- set_value: T,
553
- read_signal: SignalR[S],
554
- read_value: S,
586
+ set_signal: SignalW[SignalDatatypeT],
587
+ set_value: SignalDatatypeT,
588
+ match_signal: SignalR[SignalDatatypeV],
589
+ match_value: SignalDatatypeV | Callable[[SignalDatatypeV], bool],
555
590
  timeout: float = DEFAULT_TIMEOUT,
556
591
  set_timeout: float | None = None,
592
+ wait_for_set_completion: bool = True,
557
593
  ) -> AsyncStatus:
558
594
  """Set a signal and monitor another signal until it has the specified value.
559
595
 
560
596
  This function sets a set_signal to a specified set_value and waits for
561
- a read_signal to have the read_value.
597
+ a match_signal to have the match_value.
562
598
 
563
599
  Parameters
564
600
  ----------
@@ -566,14 +602,16 @@ async def set_and_wait_for_other_value(
566
602
  The signal to set
567
603
  set_value:
568
604
  The value to set it to
569
- read_signal:
605
+ match_signal:
570
606
  The signal to monitor
571
- read_value:
607
+ match_value:
572
608
  The value to wait for
573
609
  timeout:
574
610
  How long to wait for the signal to have the value
575
611
  set_timeout:
576
612
  How long to wait for the set to complete
613
+ wait_for_set_completion:
614
+ This will wait for set completion #More info in how-to docs
577
615
 
578
616
  Notes
579
617
  -----
@@ -582,7 +620,7 @@ async def set_and_wait_for_other_value(
582
620
  set_and_wait_for_value(device.acquire, 1, device.acquire_rbv, 1)
583
621
  """
584
622
  # Start monitoring before the set to avoid a race condition
585
- values_gen = observe_value(read_signal)
623
+ values_gen = observe_value(match_signal)
586
624
 
587
625
  # Get the initial value from the monitor to make sure we've created it
588
626
  current_value = await anext(values_gen)
@@ -590,28 +628,33 @@ async def set_and_wait_for_other_value(
590
628
  status = set_signal.set(set_value, timeout=set_timeout)
591
629
 
592
630
  # If the value was the same as before no need to wait for it to change
593
- if current_value != read_value:
631
+ if current_value != match_value:
594
632
 
595
633
  async def _wait_for_value():
596
634
  async for value in values_gen:
597
- if value == read_value:
635
+ if value == match_value:
598
636
  break
599
637
 
600
638
  try:
601
639
  await asyncio.wait_for(_wait_for_value(), timeout)
640
+ if wait_for_set_completion:
641
+ await status
642
+ return status
602
643
  except asyncio.TimeoutError as e:
603
644
  raise TimeoutError(
604
- f"{read_signal.name} didn't match {read_value} in {timeout}s"
645
+ f"{match_signal.name} didn't match {match_value} in {timeout}s"
605
646
  ) from e
606
647
 
607
- return status
648
+ return completed_status()
608
649
 
609
650
 
610
651
  async def set_and_wait_for_value(
611
- signal: SignalRW[T],
612
- value: T,
652
+ signal: SignalRW[SignalDatatypeT],
653
+ value: SignalDatatypeT,
654
+ match_value: SignalDatatypeT | Callable[[SignalDatatypeT], bool] | None = None,
613
655
  timeout: float = DEFAULT_TIMEOUT,
614
656
  status_timeout: float | None = None,
657
+ wait_for_set_completion: bool = True,
615
658
  ) -> AsyncStatus:
616
659
  """Set a signal and monitor it until it has that value.
617
660
 
@@ -626,10 +669,15 @@ async def set_and_wait_for_value(
626
669
  The signal to set
627
670
  value:
628
671
  The value to set it to
672
+ match_value:
673
+ The expected value of the signal after the operation.
674
+ Used to verify that the set operation was successful.
629
675
  timeout:
630
676
  How long to wait for the signal to have the value
631
677
  status_timeout:
632
678
  How long the returned Status will wait for the set to complete
679
+ wait_for_set_completion:
680
+ This will wait for set completion #More info in how-to docs
633
681
 
634
682
  Notes
635
683
  -----
@@ -637,6 +685,14 @@ async def set_and_wait_for_value(
637
685
 
638
686
  set_and_wait_for_value(device.acquire, 1)
639
687
  """
688
+ if match_value is None:
689
+ match_value = value
640
690
  return await set_and_wait_for_other_value(
641
- signal, value, signal, value, timeout, status_timeout
691
+ signal,
692
+ value,
693
+ signal,
694
+ match_value,
695
+ timeout,
696
+ status_timeout,
697
+ wait_for_set_completion,
642
698
  )