ophyd-async 0.2.0__py3-none-any.whl → 0.3.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 (79) hide show
  1. ophyd_async/__init__.py +1 -4
  2. ophyd_async/_version.py +2 -2
  3. ophyd_async/core/__init__.py +52 -19
  4. ophyd_async/core/_providers.py +38 -5
  5. ophyd_async/core/async_status.py +86 -40
  6. ophyd_async/core/detector.py +214 -72
  7. ophyd_async/core/device.py +91 -50
  8. ophyd_async/core/device_save_loader.py +96 -23
  9. ophyd_async/core/flyer.py +32 -246
  10. ophyd_async/core/mock_signal_backend.py +82 -0
  11. ophyd_async/core/mock_signal_utils.py +145 -0
  12. ophyd_async/core/signal.py +225 -58
  13. ophyd_async/core/signal_backend.py +8 -5
  14. ophyd_async/core/{sim_signal_backend.py → soft_signal_backend.py} +51 -49
  15. ophyd_async/core/standard_readable.py +212 -23
  16. ophyd_async/core/utils.py +123 -30
  17. ophyd_async/epics/_backend/_aioca.py +42 -44
  18. ophyd_async/epics/_backend/_p4p.py +96 -52
  19. ophyd_async/epics/_backend/common.py +25 -0
  20. ophyd_async/epics/areadetector/__init__.py +8 -4
  21. ophyd_async/epics/areadetector/aravis.py +63 -0
  22. ophyd_async/epics/areadetector/controllers/__init__.py +2 -1
  23. ophyd_async/epics/areadetector/controllers/ad_sim_controller.py +1 -1
  24. ophyd_async/epics/areadetector/controllers/aravis_controller.py +78 -0
  25. ophyd_async/epics/areadetector/controllers/kinetix_controller.py +49 -0
  26. ophyd_async/epics/areadetector/controllers/pilatus_controller.py +37 -25
  27. ophyd_async/epics/areadetector/controllers/vimba_controller.py +66 -0
  28. ophyd_async/epics/areadetector/drivers/__init__.py +6 -0
  29. ophyd_async/epics/areadetector/drivers/ad_base.py +8 -12
  30. ophyd_async/epics/areadetector/drivers/aravis_driver.py +38 -0
  31. ophyd_async/epics/areadetector/drivers/kinetix_driver.py +27 -0
  32. ophyd_async/epics/areadetector/drivers/pilatus_driver.py +8 -5
  33. ophyd_async/epics/areadetector/drivers/vimba_driver.py +63 -0
  34. ophyd_async/epics/areadetector/kinetix.py +46 -0
  35. ophyd_async/epics/areadetector/pilatus.py +45 -0
  36. ophyd_async/epics/areadetector/single_trigger_det.py +14 -6
  37. ophyd_async/epics/areadetector/utils.py +2 -12
  38. ophyd_async/epics/areadetector/vimba.py +43 -0
  39. ophyd_async/epics/areadetector/writers/_hdffile.py +21 -7
  40. ophyd_async/epics/areadetector/writers/hdf_writer.py +32 -17
  41. ophyd_async/epics/areadetector/writers/nd_file_hdf.py +19 -18
  42. ophyd_async/epics/areadetector/writers/nd_plugin.py +15 -7
  43. ophyd_async/epics/demo/__init__.py +75 -49
  44. ophyd_async/epics/motion/motor.py +67 -53
  45. ophyd_async/epics/pvi/__init__.py +3 -0
  46. ophyd_async/epics/pvi/pvi.py +318 -0
  47. ophyd_async/epics/signal/__init__.py +8 -3
  48. ophyd_async/epics/signal/signal.py +26 -9
  49. ophyd_async/log.py +130 -0
  50. ophyd_async/panda/__init__.py +21 -5
  51. ophyd_async/panda/_common_blocks.py +49 -0
  52. ophyd_async/panda/_hdf_panda.py +48 -0
  53. ophyd_async/panda/_panda_controller.py +37 -0
  54. ophyd_async/panda/_trigger.py +39 -0
  55. ophyd_async/panda/_utils.py +15 -0
  56. ophyd_async/panda/writers/__init__.py +3 -0
  57. ophyd_async/panda/writers/_hdf_writer.py +220 -0
  58. ophyd_async/panda/writers/_panda_hdf_file.py +58 -0
  59. ophyd_async/plan_stubs/__init__.py +13 -0
  60. ophyd_async/plan_stubs/ensure_connected.py +22 -0
  61. ophyd_async/plan_stubs/fly.py +149 -0
  62. ophyd_async/protocols.py +126 -0
  63. ophyd_async/sim/__init__.py +11 -0
  64. ophyd_async/sim/demo/__init__.py +3 -0
  65. ophyd_async/sim/demo/sim_motor.py +103 -0
  66. ophyd_async/sim/pattern_generator.py +318 -0
  67. ophyd_async/sim/sim_pattern_detector_control.py +55 -0
  68. ophyd_async/sim/sim_pattern_detector_writer.py +34 -0
  69. ophyd_async/sim/sim_pattern_generator.py +37 -0
  70. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/METADATA +31 -70
  71. ophyd_async-0.3.0.dist-info/RECORD +86 -0
  72. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/WHEEL +1 -1
  73. ophyd_async/epics/signal/pvi_get.py +0 -22
  74. ophyd_async/panda/panda.py +0 -294
  75. ophyd_async-0.2.0.dist-info/RECORD +0 -53
  76. /ophyd_async/panda/{table.py → _table.py} +0 -0
  77. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/LICENSE +0 -0
  78. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/entry_points.txt +0 -0
  79. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,145 @@
1
+ from contextlib import asynccontextmanager, contextmanager
2
+ from typing import Any, Callable, Iterable
3
+ from unittest.mock import Mock
4
+
5
+ from ophyd_async.core.signal import Signal
6
+ from ophyd_async.core.utils import T
7
+
8
+ from .mock_signal_backend import MockSignalBackend
9
+
10
+
11
+ def _get_mock_signal_backend(signal: Signal) -> MockSignalBackend:
12
+ assert isinstance(signal._backend, MockSignalBackend), (
13
+ "Expected to receive a `MockSignalBackend`, instead "
14
+ f" received {type(signal._backend)}. "
15
+ )
16
+ return signal._backend
17
+
18
+
19
+ def set_mock_value(signal: Signal[T], value: T):
20
+ """Set the value of a signal that is in mock mode."""
21
+ backend = _get_mock_signal_backend(signal)
22
+ backend.set_value(value)
23
+
24
+
25
+ def set_mock_put_proceeds(signal: Signal, proceeds: bool):
26
+ """Allow or block a put with wait=True from proceeding"""
27
+ backend = _get_mock_signal_backend(signal)
28
+
29
+ if proceeds:
30
+ backend.put_proceeds.set()
31
+ else:
32
+ backend.put_proceeds.clear()
33
+
34
+
35
+ @asynccontextmanager
36
+ async def mock_puts_blocked(*signals: Signal):
37
+ for signal in signals:
38
+ set_mock_put_proceeds(signal, False)
39
+ yield
40
+ for signal in signals:
41
+ set_mock_put_proceeds(signal, True)
42
+
43
+
44
+ def get_mock_put(signal: Signal) -> Mock:
45
+ """Get the mock associated with the put call on the signal."""
46
+ return _get_mock_signal_backend(signal).put_mock
47
+
48
+
49
+ def reset_mock_put_calls(signal: Signal):
50
+ backend = _get_mock_signal_backend(signal)
51
+ backend.put_mock.reset_mock()
52
+
53
+
54
+ class _SetValuesIterator:
55
+ # Garbage collected by the time __del__ is called unless we put it as a
56
+ # global attrbute here.
57
+ require_all_consumed: bool = False
58
+
59
+ def __init__(
60
+ self,
61
+ signal: Signal,
62
+ values: Iterable[Any],
63
+ require_all_consumed: bool = False,
64
+ ):
65
+ self.signal = signal
66
+ self.values = values
67
+ self.require_all_consumed = require_all_consumed
68
+ self.index = 0
69
+
70
+ self.iterator = enumerate(values, start=1)
71
+
72
+ def __iter__(self):
73
+ return self
74
+
75
+ def __next__(self):
76
+ # Will propogate StopIteration
77
+ self.index, next_value = next(self.iterator)
78
+ set_mock_value(self.signal, next_value)
79
+ return next_value
80
+
81
+ def __del__(self):
82
+ if self.require_all_consumed and self.index != len(list(self.values)):
83
+ raise AssertionError("Not all values have been consumed.")
84
+
85
+
86
+ def set_mock_values(
87
+ signal: Signal,
88
+ values: Iterable[Any],
89
+ require_all_consumed: bool = False,
90
+ ) -> _SetValuesIterator:
91
+ """Iterator to set a signal to a sequence of values, optionally repeating the
92
+ sequence.
93
+
94
+ Parameters
95
+ ----------
96
+ signal:
97
+ A signal with a `MockSignalBackend` backend.
98
+ values:
99
+ An iterable of the values to set the signal to, on each iteration
100
+ the value will be set.
101
+ require_all_consumed:
102
+ If True, an AssertionError will be raised if the iterator is deleted before
103
+ all values have been consumed.
104
+
105
+ Notes
106
+ -----
107
+ Example usage::
108
+
109
+ for value_set in set_mock_values(signal, [1, 2, 3]):
110
+ # do something
111
+
112
+ cm = set_mock_values(signal, 1, 2, 3, require_all_consumed=True):
113
+ next(cm)
114
+ # do something
115
+ """
116
+
117
+ return _SetValuesIterator(
118
+ signal,
119
+ values,
120
+ require_all_consumed=require_all_consumed,
121
+ )
122
+
123
+
124
+ @contextmanager
125
+ def _unset_side_effect_cm(put_mock: Mock):
126
+ yield
127
+ put_mock.side_effect = None
128
+
129
+
130
+ def callback_on_mock_put(signal: Signal[T], callback: Callable[[T], None]):
131
+ """For setting a callback when a backend is put to.
132
+
133
+ Can either be used in a context, with the callback being
134
+ unset on exit, or as an ordinary function.
135
+
136
+ Parameters
137
+ ----------
138
+ signal:
139
+ A signal with a `MockSignalBackend` backend.
140
+ callback:
141
+ The callback to call when the backend is put to during the context.
142
+ """
143
+ backend = _get_mock_signal_backend(signal)
144
+ backend.put_mock.side_effect = callback
145
+ return _unset_side_effect_cm(backend.put_mock)
@@ -2,26 +2,37 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import functools
5
- from typing import AsyncGenerator, Callable, Dict, Generic, Optional, Union
5
+ from typing import (
6
+ Any,
7
+ AsyncGenerator,
8
+ Callable,
9
+ Dict,
10
+ Generic,
11
+ Mapping,
12
+ Optional,
13
+ Tuple,
14
+ Type,
15
+ Union,
16
+ )
6
17
 
7
18
  from bluesky.protocols import (
8
- Descriptor,
19
+ DataKey,
9
20
  Locatable,
10
21
  Location,
11
22
  Movable,
12
- Readable,
13
23
  Reading,
14
- Stageable,
24
+ Status,
15
25
  Subscribable,
16
26
  )
17
27
 
28
+ from ophyd_async.core.mock_signal_backend import MockSignalBackend
29
+ from ophyd_async.protocols import AsyncConfigurable, AsyncReadable, AsyncStageable
30
+
18
31
  from .async_status import AsyncStatus
19
32
  from .device import Device
20
33
  from .signal_backend import SignalBackend
21
- from .sim_signal_backend import SimSignalBackend
22
- from .utils import DEFAULT_TIMEOUT, Callback, ReadingValueCallback, T
23
-
24
- _sim_backends: Dict[Signal, SimSignalBackend] = {}
34
+ from .soft_signal_backend import SoftSignalBackend
35
+ from .utils import DEFAULT_TIMEOUT, CalculatableTimeout, CalculateTimeout, Callback, T
25
36
 
26
37
 
27
38
  def _add_timeout(func):
@@ -45,34 +56,30 @@ class Signal(Device, Generic[T]):
45
56
  """A Device with the concept of a value, with R, RW, W and X flavours"""
46
57
 
47
58
  def __init__(
48
- self, backend: SignalBackend[T], timeout: Optional[float] = DEFAULT_TIMEOUT
59
+ self,
60
+ backend: SignalBackend[T],
61
+ timeout: Optional[float] = DEFAULT_TIMEOUT,
62
+ name: str = "",
49
63
  ) -> None:
50
- self._name = ""
51
64
  self._timeout = timeout
52
- self._init_backend = self._backend = backend
53
-
54
- @property
55
- def name(self) -> str:
56
- return self._name
57
-
58
- def set_name(self, name: str = ""):
59
- self._name = name
60
-
61
- async def connect(self, sim=False):
62
- if sim:
63
- self._backend = SimSignalBackend(
64
- datatype=self._init_backend.datatype, source=self._init_backend.source
65
+ self._initial_backend = self._backend = backend
66
+ super().__init__(name)
67
+
68
+ async def connect(
69
+ self, mock=False, timeout=DEFAULT_TIMEOUT, force_reconnect: bool = False
70
+ ):
71
+ if mock and not isinstance(self._backend, MockSignalBackend):
72
+ # Using a soft backend, look to the initial value
73
+ self._backend = MockSignalBackend(
74
+ initial_backend=self._initial_backend,
65
75
  )
66
- _sim_backends[self] = self._backend
67
- else:
68
- self._backend = self._init_backend
69
- _sim_backends.pop(self, None)
70
- await self._backend.connect()
76
+ self.log.debug(f"Connecting to {self.source}")
77
+ await self._backend.connect(timeout=timeout)
71
78
 
72
79
  @property
73
80
  def source(self) -> str:
74
81
  """Like ca://PV_PREFIX:SIGNAL, or "" if not set"""
75
- return self._backend.source
82
+ return self._backend.source(self.name)
76
83
 
77
84
  __lt__ = __le__ = __eq__ = __ge__ = __gt__ = __ne__ = _fail
78
85
 
@@ -91,10 +98,12 @@ class _SignalCache(Generic[T]):
91
98
  self._value: Optional[T] = None
92
99
 
93
100
  self.backend = backend
101
+ signal.log.debug(f"Making subscription on source {signal.source}")
94
102
  backend.set_callback(self._callback)
95
103
 
96
104
  def close(self):
97
105
  self.backend.set_callback(None)
106
+ self._signal.log.debug(f"Closing subscription on source {self._signal.source}")
98
107
 
99
108
  async def get_reading(self) -> Reading:
100
109
  await self._valid.wait()
@@ -107,6 +116,10 @@ class _SignalCache(Generic[T]):
107
116
  return self._value
108
117
 
109
118
  def _callback(self, reading: Reading, value: T):
119
+ self._signal.log.debug(
120
+ f"Updated subscription: reading of source {self._signal.source} changed"
121
+ f"from {self._reading} to {reading}"
122
+ )
110
123
  self._reading = reading
111
124
  self._value = value
112
125
  self._valid.set()
@@ -133,7 +146,7 @@ class _SignalCache(Generic[T]):
133
146
  return self._staged or bool(self._listeners)
134
147
 
135
148
 
136
- class SignalR(Signal[T], Readable, Stageable, Subscribable):
149
+ class SignalR(Signal[T], AsyncReadable, AsyncStageable, Subscribable):
137
150
  """Signal that can be read from and monitored"""
138
151
 
139
152
  _cache: Optional[_SignalCache] = None
@@ -166,14 +179,16 @@ class SignalR(Signal[T], Readable, Stageable, Subscribable):
166
179
  return {self.name: await self._backend_or_cache(cached).get_reading()}
167
180
 
168
181
  @_add_timeout
169
- async def describe(self) -> Dict[str, Descriptor]:
182
+ async def describe(self) -> Dict[str, DataKey]:
170
183
  """Return a single item dict with the descriptor in it"""
171
- return {self.name: await self._backend.get_descriptor()}
184
+ return {self.name: await self._backend.get_datakey(self.source)}
172
185
 
173
186
  @_add_timeout
174
187
  async def get_value(self, cached: Optional[bool] = None) -> T:
175
188
  """The current value"""
176
- return await self._backend_or_cache(cached).get_value()
189
+ value = await self._backend_or_cache(cached).get_value()
190
+ self.log.debug(f"get_value() on source {self.source} returned {value}")
191
+ return value
177
192
 
178
193
  def subscribe_value(self, function: Callback[T]):
179
194
  """Subscribe to updates in value of a device"""
@@ -198,18 +213,24 @@ class SignalR(Signal[T], Readable, Stageable, Subscribable):
198
213
  self._del_cache(self._get_cache().set_staged(False))
199
214
 
200
215
 
201
- USE_DEFAULT_TIMEOUT = "USE_DEFAULT_TIMEOUT"
202
-
203
-
204
216
  class SignalW(Signal[T], Movable):
205
217
  """Signal that can be set"""
206
218
 
207
- def set(self, value: T, wait=True, timeout=USE_DEFAULT_TIMEOUT) -> AsyncStatus:
219
+ def set(
220
+ self, value: T, wait=True, timeout: CalculatableTimeout = CalculateTimeout
221
+ ) -> AsyncStatus:
208
222
  """Set the value and return a status saying when it's done"""
209
- if timeout is USE_DEFAULT_TIMEOUT:
223
+ if timeout is CalculateTimeout:
210
224
  timeout = self._timeout
211
- coro = self._backend.put(value, wait=wait, timeout=timeout)
212
- return AsyncStatus(coro)
225
+
226
+ async def do_set():
227
+ self.log.debug(f"Putting value {value} to backend at source {self.source}")
228
+ await self._backend.put(value, wait=wait, timeout=timeout)
229
+ self.log.debug(
230
+ f"Successfully put value {value} to backend at source {self.source}"
231
+ )
232
+
233
+ return AsyncStatus(do_set())
213
234
 
214
235
 
215
236
  class SignalRW(SignalR[T], SignalW[T], Locatable):
@@ -226,34 +247,156 @@ class SignalRW(SignalR[T], SignalW[T], Locatable):
226
247
  class SignalX(Signal):
227
248
  """Signal that puts the default value"""
228
249
 
229
- def trigger(self, wait=True, timeout=USE_DEFAULT_TIMEOUT) -> AsyncStatus:
250
+ def trigger(
251
+ self, wait=True, timeout: CalculatableTimeout = CalculateTimeout
252
+ ) -> AsyncStatus:
230
253
  """Trigger the action and return a status saying when it's done"""
231
- if timeout is USE_DEFAULT_TIMEOUT:
254
+ if timeout is CalculateTimeout:
232
255
  timeout = self._timeout
233
256
  coro = self._backend.put(None, wait=wait, timeout=timeout)
234
257
  return AsyncStatus(coro)
235
258
 
236
259
 
237
- def set_sim_value(signal: Signal[T], value: T):
238
- """Set the value of a signal that is in sim mode."""
239
- _sim_backends[signal]._set_value(value)
260
+ def soft_signal_rw(
261
+ datatype: Optional[Type[T]] = None,
262
+ initial_value: Optional[T] = None,
263
+ name: str = "",
264
+ ) -> SignalRW[T]:
265
+ """Creates a read-writable Signal with a SoftSignalBackend"""
266
+ signal = SignalRW(SoftSignalBackend(datatype, initial_value), name=name)
267
+ return signal
268
+
269
+
270
+ def soft_signal_r_and_setter(
271
+ datatype: Optional[Type[T]] = None,
272
+ initial_value: Optional[T] = None,
273
+ name: str = "",
274
+ ) -> Tuple[SignalR[T], Callable[[T], None]]:
275
+ """Returns a tuple of a read-only Signal and a callable through
276
+ which the signal can be internally modified within the device. Use
277
+ soft_signal_rw if you want a device that is externally modifiable
278
+ """
279
+ backend = SoftSignalBackend(datatype, initial_value)
280
+ signal = SignalR(backend, name=name)
240
281
 
282
+ return (signal, backend.set_value)
241
283
 
242
- def set_sim_put_proceeds(signal: Signal[T], proceeds: bool):
243
- """Allow or block a put with wait=True from proceeding"""
244
- event = _sim_backends[signal].put_proceeds
245
- if proceeds:
246
- event.set()
247
- else:
248
- event.clear()
249
284
 
285
+ def _generate_assert_error_msg(
286
+ name: str, expected_result: str, actuall_result: str
287
+ ) -> str:
288
+ WARNING = "\033[93m"
289
+ FAIL = "\033[91m"
290
+ ENDC = "\033[0m"
291
+ return (
292
+ f"Expected {WARNING}{name}{ENDC} to produce"
293
+ + f"\n{FAIL}{actuall_result}{ENDC}"
294
+ + f"\nbut actually got \n{FAIL}{expected_result}{ENDC}"
295
+ )
296
+
297
+
298
+ async def assert_value(signal: SignalR[T], value: Any) -> None:
299
+ """Assert a signal's value and compare it an expected signal.
300
+
301
+ Parameters
302
+ ----------
303
+ signal:
304
+ signal with get_value.
305
+ value:
306
+ The expected value from the signal.
307
+
308
+ Notes
309
+ -----
310
+ Example usage::
311
+ await assert_value(signal, value)
312
+
313
+ """
314
+ actual_value = await signal.get_value()
315
+ assert actual_value == value, _generate_assert_error_msg(
316
+ signal.name, value, actual_value
317
+ )
318
+
319
+
320
+ async def assert_reading(
321
+ readable: AsyncReadable, expected_reading: Mapping[str, Reading]
322
+ ) -> None:
323
+ """Assert readings from readable.
324
+
325
+ Parameters
326
+ ----------
327
+ readable:
328
+ Callable with readable.read function that generate readings.
329
+
330
+ reading:
331
+ The expected readings from the readable.
332
+
333
+ Notes
334
+ -----
335
+ Example usage::
336
+ await assert_reading(readable, reading)
337
+
338
+ """
339
+ actual_reading = await readable.read()
340
+ assert expected_reading == actual_reading, _generate_assert_error_msg(
341
+ readable.name, expected_reading, actual_reading
342
+ )
343
+
344
+
345
+ async def assert_configuration(
346
+ configurable: AsyncConfigurable,
347
+ configuration: Mapping[str, Reading],
348
+ ) -> None:
349
+ """Assert readings from Configurable.
350
+
351
+ Parameters
352
+ ----------
353
+ configurable:
354
+ Configurable with Configurable.read function that generate readings.
355
+
356
+ configuration:
357
+ The expected readings from configurable.
358
+
359
+ Notes
360
+ -----
361
+ Example usage::
362
+ await assert_configuration(configurable configuration)
363
+
364
+ """
365
+ actual_configurable = await configurable.read_configuration()
366
+ assert configuration == actual_configurable, _generate_assert_error_msg(
367
+ configurable.name, configuration, actual_configurable
368
+ )
250
369
 
251
- def set_sim_callback(signal: Signal[T], callback: ReadingValueCallback[T]) -> None:
252
- """Monitor the value of a signal that is in sim mode"""
253
- return _sim_backends[signal].set_callback(callback)
254
370
 
371
+ def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int):
372
+ """Assert emitted document generated by running a Bluesky plan
255
373
 
256
- async def observe_value(signal: SignalR[T]) -> AsyncGenerator[T, None]:
374
+ Parameters
375
+ ----------
376
+ Doc:
377
+ A dictionary
378
+
379
+ numbers:
380
+ expected emission in kwarg from
381
+
382
+ Notes
383
+ -----
384
+ Example usage::
385
+ assert_emitted(docs, start=1, descriptor=1,
386
+ resource=1, datum=1, event=1, stop=1)
387
+ """
388
+ assert list(docs) == list(numbers), _generate_assert_error_msg(
389
+ "documents", list(numbers), list(docs)
390
+ )
391
+ actual_numbers = {name: len(d) for name, d in docs.items()}
392
+ assert actual_numbers == numbers, _generate_assert_error_msg(
393
+ "emitted", numbers, actual_numbers
394
+ )
395
+
396
+
397
+ async def observe_value(
398
+ signal: SignalR[T], timeout: float | None = None, done_status: Status | None = None
399
+ ) -> AsyncGenerator[T, None]:
257
400
  """Subscribe to the value of a signal so it can be iterated from.
258
401
 
259
402
  Parameters
@@ -261,6 +404,12 @@ async def observe_value(signal: SignalR[T]) -> AsyncGenerator[T, None]:
261
404
  signal:
262
405
  Call subscribe_value on this at the start, and clear_sub on it at the
263
406
  end
407
+ timeout:
408
+ If given, how long to wait for each updated value in seconds. If an update
409
+ is not produced in this time then raise asyncio.TimeoutError
410
+ done_status:
411
+ If this status is complete, stop observing and make the iterator return.
412
+ If it raises an exception then this exception will be raised by the iterator.
264
413
 
265
414
  Notes
266
415
  -----
@@ -269,11 +418,29 @@ async def observe_value(signal: SignalR[T]) -> AsyncGenerator[T, None]:
269
418
  async for value in observe_value(sig):
270
419
  do_something_with(value)
271
420
  """
272
- q: asyncio.Queue[T] = asyncio.Queue()
421
+
422
+ q: asyncio.Queue[T | Status] = asyncio.Queue()
423
+ if timeout is None:
424
+ get_value = q.get
425
+ else:
426
+
427
+ async def get_value():
428
+ return await asyncio.wait_for(q.get(), timeout)
429
+
430
+ if done_status is not None:
431
+ done_status.add_callback(q.put_nowait)
432
+
273
433
  signal.subscribe_value(q.put_nowait)
274
434
  try:
275
435
  while True:
276
- yield await q.get()
436
+ item = await get_value()
437
+ if done_status and item is done_status:
438
+ if exc := done_status.exception():
439
+ raise exc
440
+ else:
441
+ break
442
+ else:
443
+ yield item
277
444
  finally:
278
445
  signal.clear_sub(q.put_nowait)
279
446
 
@@ -1,9 +1,9 @@
1
1
  from abc import abstractmethod
2
2
  from typing import Generic, Optional, Type
3
3
 
4
- from bluesky.protocols import Descriptor, Reading
4
+ from bluesky.protocols import DataKey, Reading
5
5
 
6
- from .utils import ReadingValueCallback, T
6
+ from .utils import DEFAULT_TIMEOUT, ReadingValueCallback, T
7
7
 
8
8
 
9
9
  class SignalBackend(Generic[T]):
@@ -13,10 +13,13 @@ class SignalBackend(Generic[T]):
13
13
  datatype: Optional[Type[T]] = None
14
14
 
15
15
  #: Like ca://PV_PREFIX:SIGNAL
16
- source: str = ""
16
+ @abstractmethod
17
+ def source(self, name: str) -> str:
18
+ """Return source of signal. Signals may pass a name to the backend, which can be
19
+ used or discarded."""
17
20
 
18
21
  @abstractmethod
19
- async def connect(self):
22
+ async def connect(self, timeout: float = DEFAULT_TIMEOUT):
20
23
  """Connect to underlying hardware"""
21
24
 
22
25
  @abstractmethod
@@ -24,7 +27,7 @@ class SignalBackend(Generic[T]):
24
27
  """Put a value to the PV, if wait then wait for completion for up to timeout"""
25
28
 
26
29
  @abstractmethod
27
- async def get_descriptor(self) -> Descriptor:
30
+ async def get_datakey(self, source: str) -> DataKey:
28
31
  """Metadata like source, dtype, shape, precision, units"""
29
32
 
30
33
  @abstractmethod