ophyd-async 0.3a3__py3-none-any.whl → 0.3a5__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 (40) hide show
  1. ophyd_async/_version.py +1 -1
  2. ophyd_async/core/__init__.py +26 -11
  3. ophyd_async/core/async_status.py +96 -33
  4. ophyd_async/core/detector.py +22 -28
  5. ophyd_async/core/device.py +50 -14
  6. ophyd_async/core/mock_signal_backend.py +85 -0
  7. ophyd_async/core/mock_signal_utils.py +149 -0
  8. ophyd_async/core/signal.py +93 -53
  9. ophyd_async/core/{sim_signal_backend.py → soft_signal_backend.py} +23 -33
  10. ophyd_async/core/utils.py +19 -0
  11. ophyd_async/epics/_backend/_aioca.py +11 -7
  12. ophyd_async/epics/_backend/_p4p.py +11 -7
  13. ophyd_async/epics/_backend/common.py +17 -17
  14. ophyd_async/epics/areadetector/__init__.py +0 -4
  15. ophyd_async/epics/areadetector/aravis.py +1 -5
  16. ophyd_async/epics/areadetector/controllers/aravis_controller.py +6 -1
  17. ophyd_async/epics/areadetector/drivers/ad_base.py +12 -10
  18. ophyd_async/epics/areadetector/drivers/aravis_driver.py +6 -122
  19. ophyd_async/epics/areadetector/drivers/kinetix_driver.py +7 -4
  20. ophyd_async/epics/areadetector/drivers/pilatus_driver.py +5 -2
  21. ophyd_async/epics/areadetector/drivers/vimba_driver.py +12 -7
  22. ophyd_async/epics/areadetector/utils.py +2 -12
  23. ophyd_async/epics/areadetector/writers/nd_file_hdf.py +21 -19
  24. ophyd_async/epics/areadetector/writers/nd_plugin.py +6 -7
  25. ophyd_async/epics/demo/__init__.py +27 -32
  26. ophyd_async/epics/motion/motor.py +40 -37
  27. ophyd_async/epics/pvi/pvi.py +13 -13
  28. ophyd_async/epics/signal/__init__.py +8 -1
  29. ophyd_async/panda/_hdf_panda.py +3 -3
  30. ophyd_async/planstubs/__init__.py +5 -1
  31. ophyd_async/planstubs/ensure_connected.py +22 -0
  32. ophyd_async/protocols.py +32 -2
  33. ophyd_async/sim/demo/sim_motor.py +50 -35
  34. ophyd_async/sim/pattern_generator.py +5 -5
  35. {ophyd_async-0.3a3.dist-info → ophyd_async-0.3a5.dist-info}/METADATA +2 -2
  36. {ophyd_async-0.3a3.dist-info → ophyd_async-0.3a5.dist-info}/RECORD +40 -37
  37. {ophyd_async-0.3a3.dist-info → ophyd_async-0.3a5.dist-info}/LICENSE +0 -0
  38. {ophyd_async-0.3a3.dist-info → ophyd_async-0.3a5.dist-info}/WHEEL +0 -0
  39. {ophyd_async-0.3a3.dist-info → ophyd_async-0.3a5.dist-info}/entry_points.txt +0 -0
  40. {ophyd_async-0.3a3.dist-info → ophyd_async-0.3a5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,149 @@
1
+ from contextlib import asynccontextmanager, contextmanager
2
+ from typing import Any, Callable, Generator, Iterable, Iterator, List
3
+ from unittest.mock import ANY, 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[T], 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: List[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 assert_mock_put_called_with(signal: Signal, value: Any, wait=ANY, timeout=ANY):
45
+ backend = _get_mock_signal_backend(signal)
46
+ backend.put_mock.assert_called_with(value, wait=wait, timeout=timeout)
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(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
+ ) -> Iterator[Any]:
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
+ # linting isn't smart enought to realize @contextmanager will give use a
131
+ # ContextManager[None]
132
+ def callback_on_mock_put(
133
+ signal: Signal, callback: Callable[[T], None]
134
+ ) -> Generator[None, None, None]:
135
+ """For setting a callback when a backend is put to.
136
+
137
+ Can either be used in a context, with the callback being
138
+ unset on exit, or as an ordinary function.
139
+
140
+ Parameters
141
+ ----------
142
+ signal:
143
+ A signal with a `MockSignalBackend` backend.
144
+ callback:
145
+ The callback to call when the backend is put to during the context.
146
+ """
147
+ backend = _get_mock_signal_backend(signal)
148
+ backend.put_mock.side_effect = callback
149
+ return _unset_side_effect_cm(backend.put_mock)
@@ -21,18 +21,18 @@ from bluesky.protocols import (
21
21
  Location,
22
22
  Movable,
23
23
  Reading,
24
+ Status,
24
25
  Subscribable,
25
26
  )
26
27
 
28
+ from ophyd_async.core.mock_signal_backend import MockSignalBackend
27
29
  from ophyd_async.protocols import AsyncConfigurable, AsyncReadable, AsyncStageable
28
30
 
29
31
  from .async_status import AsyncStatus
30
32
  from .device import Device
31
33
  from .signal_backend import SignalBackend
32
- from .sim_signal_backend import SimSignalBackend
33
- from .utils import DEFAULT_TIMEOUT, Callback, ReadingValueCallback, T
34
-
35
- _sim_backends: Dict[Signal, SimSignalBackend] = {}
34
+ from .soft_signal_backend import SoftSignalBackend
35
+ from .utils import DEFAULT_TIMEOUT, Callback, T
36
36
 
37
37
 
38
38
  def _add_timeout(func):
@@ -61,17 +61,19 @@ class Signal(Device, Generic[T]):
61
61
  timeout: Optional[float] = DEFAULT_TIMEOUT,
62
62
  name: str = "",
63
63
  ) -> None:
64
- super().__init__(name)
65
64
  self._timeout = timeout
66
- self._init_backend = self._backend = backend
65
+ self._initial_backend = self._backend = backend
66
+ super().__init__(name)
67
67
 
68
- async def connect(self, sim=False, timeout=DEFAULT_TIMEOUT):
69
- if sim:
70
- self._backend = SimSignalBackend(datatype=self._init_backend.datatype)
71
- _sim_backends[self] = self._backend
72
- else:
73
- self._backend = self._init_backend
74
- _sim_backends.pop(self, None)
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,
75
+ )
76
+ self.log.debug(f"Connecting to {self.source}")
75
77
  await self._backend.connect(timeout=timeout)
76
78
 
77
79
  @property
@@ -96,10 +98,12 @@ class _SignalCache(Generic[T]):
96
98
  self._value: Optional[T] = None
97
99
 
98
100
  self.backend = backend
101
+ signal.log.debug(f"Making subscription on source {signal.source}")
99
102
  backend.set_callback(self._callback)
100
103
 
101
104
  def close(self):
102
105
  self.backend.set_callback(None)
106
+ self._signal.log.debug(f"Closing subscription on source {self._signal.source}")
103
107
 
104
108
  async def get_reading(self) -> Reading:
105
109
  await self._valid.wait()
@@ -112,6 +116,10 @@ class _SignalCache(Generic[T]):
112
116
  return self._value
113
117
 
114
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
+ )
115
123
  self._reading = reading
116
124
  self._value = value
117
125
  self._valid.set()
@@ -178,7 +186,9 @@ class SignalR(Signal[T], AsyncReadable, AsyncStageable, Subscribable):
178
186
  @_add_timeout
179
187
  async def get_value(self, cached: Optional[bool] = None) -> T:
180
188
  """The current value"""
181
- 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
182
192
 
183
193
  def subscribe_value(self, function: Callback[T]):
184
194
  """Subscribe to updates in value of a device"""
@@ -213,8 +223,15 @@ class SignalW(Signal[T], Movable):
213
223
  """Set the value and return a status saying when it's done"""
214
224
  if timeout is USE_DEFAULT_TIMEOUT:
215
225
  timeout = self._timeout
216
- coro = self._backend.put(value, wait=wait, timeout=timeout)
217
- return AsyncStatus(coro)
226
+
227
+ async def do_set():
228
+ self.log.debug(f"Putting value {value} to backend at source {self.source}")
229
+ await self._backend.put(value, wait=wait, timeout=timeout)
230
+ self.log.debug(
231
+ f"Successfully put value {value} to backend at source {self.source}"
232
+ )
233
+
234
+ return AsyncStatus(do_set())
218
235
 
219
236
 
220
237
  class SignalRW(SignalR[T], SignalW[T], Locatable):
@@ -239,47 +256,42 @@ class SignalX(Signal):
239
256
  return AsyncStatus(coro)
240
257
 
241
258
 
242
- def set_sim_value(signal: Signal[T], value: T):
243
- """Set the value of a signal that is in sim mode."""
244
- _sim_backends[signal]._set_value(value)
245
-
246
-
247
- def set_sim_put_proceeds(signal: Signal[T], proceeds: bool):
248
- """Allow or block a put with wait=True from proceeding"""
249
- event = _sim_backends[signal].put_proceeds
250
- if proceeds:
251
- event.set()
252
- else:
253
- event.clear()
254
-
255
-
256
- def set_sim_callback(signal: Signal[T], callback: ReadingValueCallback[T]) -> None:
257
- """Monitor the value of a signal that is in sim mode"""
258
- return _sim_backends[signal].set_callback(callback)
259
-
260
-
261
259
  def soft_signal_rw(
262
260
  datatype: Optional[Type[T]] = None,
263
261
  initial_value: Optional[T] = None,
264
262
  name: str = "",
265
263
  ) -> SignalRW[T]:
266
- """Creates a read-writable Signal with a SimSignalBackend"""
267
- signal = SignalRW(SimSignalBackend(datatype, initial_value), name=name)
264
+ """Creates a read-writable Signal with a SoftSignalBackend"""
265
+ signal = SignalRW(SoftSignalBackend(datatype, initial_value), name=name)
268
266
  return signal
269
267
 
270
268
 
271
- def soft_signal_r_and_backend(
269
+ def soft_signal_r_and_setter(
272
270
  datatype: Optional[Type[T]] = None,
273
271
  initial_value: Optional[T] = None,
274
272
  name: str = "",
275
- ) -> Tuple[SignalR[T], SimSignalBackend]:
276
- """Returns a tuple of a read-only Signal and its SimSignalBackend through
273
+ ) -> Tuple[SignalR[T], Callable[[T]]]:
274
+ """Returns a tuple of a read-only Signal and a callable through
277
275
  which the signal can be internally modified within the device. Use
278
276
  soft_signal_rw if you want a device that is externally modifiable
279
277
  """
280
- backend = SimSignalBackend(datatype, initial_value)
278
+ backend = SoftSignalBackend(datatype, initial_value)
281
279
  signal = SignalR(backend, name=name)
282
- return (signal, backend)
280
+
281
+ return (signal, backend.set_value)
282
+
283
+
284
+ def _generate_assert_error_msg(
285
+ name: str, expected_result: str, actuall_result: str
286
+ ) -> str:
287
+ WARNING = "\033[93m"
288
+ FAIL = "\033[91m"
289
+ ENDC = "\033[0m"
290
+ return (
291
+ f"Expected {WARNING}{name}{ENDC} to produce"
292
+ + f"\n{FAIL}{actuall_result}{ENDC}"
293
+ + f"\nbut actually got \n{FAIL}{expected_result}{ENDC}"
294
+ )
283
295
 
284
296
 
285
297
  async def assert_value(signal: SignalR[T], value: Any) -> None:
@@ -298,11 +310,14 @@ async def assert_value(signal: SignalR[T], value: Any) -> None:
298
310
  await assert_value(signal, value)
299
311
 
300
312
  """
301
- assert await signal.get_value() == value
313
+ actual_value = await signal.get_value()
314
+ assert actual_value == value, _generate_assert_error_msg(
315
+ signal.name, value, actual_value
316
+ )
302
317
 
303
318
 
304
319
  async def assert_reading(
305
- readable: AsyncReadable, reading: Mapping[str, Reading]
320
+ readable: AsyncReadable, expected_reading: Mapping[str, Reading]
306
321
  ) -> None:
307
322
  """Assert readings from readable.
308
323
 
@@ -320,7 +335,10 @@ async def assert_reading(
320
335
  await assert_reading(readable, reading)
321
336
 
322
337
  """
323
- assert await readable.read() == reading
338
+ actual_reading = await readable.read()
339
+ assert expected_reading == actual_reading, _generate_assert_error_msg(
340
+ readable.name, expected_reading, actual_reading
341
+ )
324
342
 
325
343
 
326
344
  async def assert_configuration(
@@ -343,7 +361,10 @@ async def assert_configuration(
343
361
  await assert_configuration(configurable configuration)
344
362
 
345
363
  """
346
- assert await configurable.read_configuration() == configuration
364
+ actual_configurable = await configurable.read_configuration()
365
+ assert configuration == actual_configurable, _generate_assert_error_msg(
366
+ configurable.name, configuration, actual_configurable
367
+ )
347
368
 
348
369
 
349
370
  def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int):
@@ -363,11 +384,18 @@ def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int):
363
384
  assert_emitted(docs, start=1, descriptor=1,
364
385
  resource=1, datum=1, event=1, stop=1)
365
386
  """
366
- assert list(docs) == list(numbers)
367
- assert {name: len(d) for name, d in docs.items()} == numbers
368
-
369
-
370
- async def observe_value(signal: SignalR[T], timeout=None) -> AsyncGenerator[T, None]:
387
+ assert list(docs) == list(numbers), _generate_assert_error_msg(
388
+ "documents", list(numbers), list(docs)
389
+ )
390
+ actual_numbers = {name: len(d) for name, d in docs.items()}
391
+ assert actual_numbers == numbers, _generate_assert_error_msg(
392
+ "emitted", numbers, actual_numbers
393
+ )
394
+
395
+
396
+ async def observe_value(
397
+ signal: SignalR[T], timeout=None, done_status: Status | None = None
398
+ ) -> AsyncGenerator[T, None]:
371
399
  """Subscribe to the value of a signal so it can be iterated from.
372
400
 
373
401
  Parameters
@@ -375,6 +403,8 @@ async def observe_value(signal: SignalR[T], timeout=None) -> AsyncGenerator[T, N
375
403
  signal:
376
404
  Call subscribe_value on this at the start, and clear_sub on it at the
377
405
  end
406
+ done_status:
407
+ If this status is complete, stop observing and make the iterator return.
378
408
 
379
409
  Notes
380
410
  -----
@@ -383,7 +413,10 @@ async def observe_value(signal: SignalR[T], timeout=None) -> AsyncGenerator[T, N
383
413
  async for value in observe_value(sig):
384
414
  do_something_with(value)
385
415
  """
386
- q: asyncio.Queue[T] = asyncio.Queue()
416
+
417
+ class StatusIsDone: ...
418
+
419
+ q: asyncio.Queue[T | StatusIsDone] = asyncio.Queue()
387
420
  if timeout is None:
388
421
  get_value = q.get
389
422
  else:
@@ -391,10 +424,17 @@ async def observe_value(signal: SignalR[T], timeout=None) -> AsyncGenerator[T, N
391
424
  async def get_value():
392
425
  return await asyncio.wait_for(q.get(), timeout)
393
426
 
427
+ if done_status is not None:
428
+ done_status.add_callback(lambda _: q.put_nowait(StatusIsDone()))
429
+
394
430
  signal.subscribe_value(q.put_nowait)
395
431
  try:
396
432
  while True:
397
- yield await get_value()
433
+ item = await get_value()
434
+ if not isinstance(item, StatusIsDone):
435
+ yield item
436
+ else:
437
+ break
398
438
  finally:
399
439
  signal.clear_sub(q.put_nowait)
400
440
 
@@ -1,12 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
3
  import inspect
5
4
  import time
6
5
  from collections import abc
7
6
  from dataclasses import dataclass
8
7
  from enum import Enum
9
- from typing import Any, Dict, Generic, Optional, Type, Union, cast, get_origin
8
+ from typing import Dict, Generic, Optional, Type, Union, cast, get_origin
10
9
 
11
10
  import numpy as np
12
11
  from bluesky.protocols import DataKey, Dtype, Reading
@@ -22,7 +21,7 @@ primitive_dtypes: Dict[type, Dtype] = {
22
21
  }
23
22
 
24
23
 
25
- class SimConverter(Generic[T]):
24
+ class SoftConverter(Generic[T]):
26
25
  def value(self, value: T) -> T:
27
26
  return value
28
27
 
@@ -55,7 +54,7 @@ class SimConverter(Generic[T]):
55
54
  return datatype()
56
55
 
57
56
 
58
- class SimArrayConverter(SimConverter):
57
+ class SoftArrayConverter(SoftConverter):
59
58
  def get_datakey(self, source: str, value) -> DataKey:
60
59
  return {"source": source, "dtype": "array", "shape": [len(value)]}
61
60
 
@@ -70,7 +69,7 @@ class SimArrayConverter(SimConverter):
70
69
 
71
70
 
72
71
  @dataclass
73
- class SimEnumConverter(SimConverter):
72
+ class SoftEnumConverter(SoftConverter):
74
73
  enum_class: Type[Enum]
75
74
 
76
75
  def write_value(self, value: Union[Enum, str]) -> Enum:
@@ -90,26 +89,21 @@ class SimEnumConverter(SimConverter):
90
89
  return cast(T, list(datatype.__members__.values())[0]) # type: ignore
91
90
 
92
91
 
93
- class DisconnectedSimConverter(SimConverter):
94
- def __getattribute__(self, __name: str) -> Any:
95
- raise NotImplementedError("No PV has been set as connect() has not been called")
96
-
97
-
98
92
  def make_converter(datatype):
99
93
  is_array = get_dtype(datatype) is not None
100
94
  is_sequence = get_origin(datatype) == abc.Sequence
101
95
  is_enum = issubclass(datatype, Enum) if inspect.isclass(datatype) else False
102
96
 
103
97
  if is_array or is_sequence:
104
- return SimArrayConverter()
98
+ return SoftArrayConverter()
105
99
  if is_enum:
106
- return SimEnumConverter(datatype)
100
+ return SoftEnumConverter(datatype)
107
101
 
108
- return SimConverter()
102
+ return SoftConverter()
109
103
 
110
104
 
111
- class SimSignalBackend(SignalBackend[T]):
112
- """An simulated backend to a Signal, created with ``Signal.connect(sim=True)``"""
105
+ class SoftSignalBackend(SignalBackend[T]):
106
+ """An backend to a soft Signal, for test signals see ``MockSignalBackend``."""
113
107
 
114
108
  _value: T
115
109
  _initial_value: Optional[T]
@@ -122,25 +116,23 @@ class SimSignalBackend(SignalBackend[T]):
122
116
  initial_value: Optional[T] = None,
123
117
  ) -> None:
124
118
  self.datatype = datatype
125
- self.converter: SimConverter = DisconnectedSimConverter()
126
119
  self._initial_value = initial_value
127
- self.put_proceeds = asyncio.Event()
128
- self.put_proceeds.set()
129
- self.callback: Optional[ReadingValueCallback[T]] = None
130
-
131
- def source(self, name: str) -> str:
132
- return f"soft://{name}"
133
-
134
- async def connect(self, timeout: float = DEFAULT_TIMEOUT) -> None:
135
- self.converter = make_converter(self.datatype)
120
+ self.converter: SoftConverter = make_converter(datatype)
136
121
  if self._initial_value is None:
137
122
  self._initial_value = self.converter.make_initial_value(self.datatype)
138
123
  else:
139
- # convert potentially unconverted initial value passed to init method
140
124
  self._initial_value = self.converter.write_value(self._initial_value)
125
+
126
+ self.callback: Optional[ReadingValueCallback[T]] = None
141
127
  self._severity = 0
128
+ self.set_value(self._initial_value)
142
129
 
143
- await self.put(None)
130
+ def source(self, name: str) -> str:
131
+ return f"soft://{name}"
132
+
133
+ async def connect(self, timeout: float = DEFAULT_TIMEOUT) -> None:
134
+ """Connection isn't required for soft signals."""
135
+ pass
144
136
 
145
137
  async def put(self, value: Optional[T], wait=True, timeout=None):
146
138
  write_value = (
@@ -148,13 +140,11 @@ class SimSignalBackend(SignalBackend[T]):
148
140
  if value is not None
149
141
  else self._initial_value
150
142
  )
151
- self._set_value(write_value)
152
143
 
153
- if wait:
154
- await asyncio.wait_for(self.put_proceeds.wait(), timeout)
144
+ self.set_value(write_value)
155
145
 
156
- def _set_value(self, value: T):
157
- """Method to bypass asynchronous logic, designed to only be used in tests."""
146
+ def set_value(self, value: T):
147
+ """Method to bypass asynchronous logic."""
158
148
  self._value = value
159
149
  self._timestamp = time.monotonic()
160
150
  reading: Reading = self.converter.reading(
@@ -174,7 +164,7 @@ class SimSignalBackend(SignalBackend[T]):
174
164
  return self.converter.value(self._value)
175
165
 
176
166
  async def get_setpoint(self) -> T:
177
- """For a simulated backend, the setpoint and readback values are the same."""
167
+ """For a soft signal, the setpoint and readback values are the same."""
178
168
  return await self.get_value()
179
169
 
180
170
  def set_callback(self, callback: Optional[ReadingValueCallback[T]]) -> None:
ophyd_async/core/utils.py CHANGED
@@ -2,13 +2,16 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ from dataclasses import dataclass
5
6
  from typing import (
6
7
  Awaitable,
7
8
  Callable,
8
9
  Dict,
10
+ Generic,
9
11
  Iterable,
10
12
  List,
11
13
  Optional,
14
+ ParamSpec,
12
15
  Type,
13
16
  TypeVar,
14
17
  Union,
@@ -18,6 +21,7 @@ import numpy as np
18
21
  from bluesky.protocols import Reading
19
22
 
20
23
  T = TypeVar("T")
24
+ P = ParamSpec("P")
21
25
  Callback = Callable[[T], None]
22
26
 
23
27
  #: A function that will be called with the Reading and value when the
@@ -77,6 +81,21 @@ class NotConnected(Exception):
77
81
  return self.format_error_string(indent="")
78
82
 
79
83
 
84
+ @dataclass(frozen=True)
85
+ class WatcherUpdate(Generic[T]):
86
+ """A dataclass such that, when expanded, it provides the kwargs for a watcher"""
87
+
88
+ current: T
89
+ initial: T
90
+ target: T
91
+ name: str | None = None
92
+ unit: str | None = None
93
+ precision: float | None = None
94
+ fraction: float | None = None
95
+ time_elapsed: float | None = None
96
+ time_remaining: float | None = None
97
+
98
+
80
99
  async def wait_for_connection(**coros: Awaitable[None]):
81
100
  """Call many underlying signals, accumulating exceptions and returning them
82
101
 
@@ -28,7 +28,7 @@ from ophyd_async.core import (
28
28
  )
29
29
  from ophyd_async.core.utils import DEFAULT_TIMEOUT, NotConnected
30
30
 
31
- from .common import get_supported_enum_class
31
+ from .common import get_supported_values
32
32
 
33
33
  dbr_to_dtype: Dict[Dbr, Dtype] = {
34
34
  dbr.DBR_STRING: "string",
@@ -79,7 +79,7 @@ class CaArrayConverter(CaConverter):
79
79
 
80
80
  @dataclass
81
81
  class CaEnumConverter(CaConverter):
82
- enum_class: Type[Enum]
82
+ choices: dict[str, str]
83
83
 
84
84
  def write_value(self, value: Union[Enum, str]):
85
85
  if isinstance(value, Enum):
@@ -88,11 +88,15 @@ class CaEnumConverter(CaConverter):
88
88
  return value
89
89
 
90
90
  def value(self, value: AugmentedValue):
91
- return self.enum_class(value)
91
+ return self.choices[value]
92
92
 
93
93
  def get_datakey(self, source: str, value: AugmentedValue) -> DataKey:
94
- choices = [e.value for e in self.enum_class]
95
- return {"source": source, "dtype": "string", "shape": [], "choices": choices}
94
+ return {
95
+ "source": source,
96
+ "dtype": "string",
97
+ "shape": [],
98
+ "choices": list(self.choices),
99
+ }
96
100
 
97
101
 
98
102
  class DisconnectedCaConverter(CaConverter):
@@ -138,8 +142,8 @@ def make_converter(
138
142
  pv_choices = get_unique(
139
143
  {k: tuple(v.enums) for k, v in values.items()}, "choices"
140
144
  )
141
- enum_class = get_supported_enum_class(pv, datatype, pv_choices)
142
- return CaEnumConverter(dbr.DBR_STRING, None, enum_class)
145
+ supported_values = get_supported_values(pv, datatype, pv_choices)
146
+ return CaEnumConverter(dbr.DBR_STRING, None, supported_values)
143
147
  else:
144
148
  value = list(values.values())[0]
145
149
  # Done the dbr check, so enough to check one of the values