ophyd-async 0.3a4__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.
ophyd_async/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.3a4'
15
+ __version__ = version = '0.3a5'
16
16
  __version_tuple__ = version_tuple = (0, 3)
@@ -5,7 +5,7 @@ from ._providers import (
5
5
  ShapeProvider,
6
6
  StaticDirectoryProvider,
7
7
  )
8
- from .async_status import AsyncStatus
8
+ from .async_status import AsyncStatus, WatchableAsyncStatus
9
9
  from .detector import (
10
10
  DetectorControl,
11
11
  DetectorTrigger,
@@ -96,6 +96,7 @@ __all__ = [
96
96
  "set_mock_value",
97
97
  "wait_for_value",
98
98
  "AsyncStatus",
99
+ "WatchableAsyncStatus",
99
100
  "DirectoryInfo",
100
101
  "DirectoryProvider",
101
102
  "NameProvider",
@@ -2,30 +2,42 @@
2
2
 
3
3
  import asyncio
4
4
  import functools
5
- from typing import Awaitable, Callable, Coroutine, List, Optional, cast
5
+ import time
6
+ from dataclasses import asdict, replace
7
+ from typing import (
8
+ AsyncIterator,
9
+ Awaitable,
10
+ Callable,
11
+ Generic,
12
+ SupportsFloat,
13
+ Type,
14
+ TypeVar,
15
+ cast,
16
+ )
6
17
 
7
18
  from bluesky.protocols import Status
8
19
 
9
- from .utils import Callback, T
20
+ from ..protocols import Watcher
21
+ from .utils import Callback, P, T, WatcherUpdate
10
22
 
23
+ AS = TypeVar("AS", bound="AsyncStatus")
24
+ WAS = TypeVar("WAS", bound="WatchableAsyncStatus")
11
25
 
12
- class AsyncStatus(Status):
26
+
27
+ class AsyncStatusBase(Status):
13
28
  """Convert asyncio awaitable to bluesky Status interface"""
14
29
 
15
- def __init__(
16
- self,
17
- awaitable: Awaitable,
18
- watchers: Optional[List[Callable]] = None,
19
- ):
30
+ def __init__(self, awaitable: Awaitable, timeout: SupportsFloat | None = None):
31
+ if isinstance(timeout, SupportsFloat):
32
+ timeout = float(timeout)
20
33
  if isinstance(awaitable, asyncio.Task):
21
34
  self.task = awaitable
22
35
  else:
23
- self.task = asyncio.create_task(awaitable) # type: ignore
24
-
36
+ self.task = asyncio.create_task(
37
+ asyncio.wait_for(awaitable, timeout=timeout)
38
+ )
25
39
  self.task.add_done_callback(self._run_callbacks)
26
-
27
- self._callbacks = cast(List[Callback[Status]], [])
28
- self._watchers = watchers
40
+ self._callbacks: list[Callback[Status]] = []
29
41
 
30
42
  def __await__(self):
31
43
  return self.task.__await__()
@@ -41,15 +53,11 @@ class AsyncStatus(Status):
41
53
  for callback in self._callbacks:
42
54
  callback(self)
43
55
 
44
- # TODO: remove ignore and bump min version when bluesky v1.12.0 is released
45
- def exception( # type: ignore
46
- self, timeout: Optional[float] = 0.0
47
- ) -> Optional[BaseException]:
56
+ def exception(self, timeout: float | None = 0.0) -> BaseException | None:
48
57
  if timeout != 0.0:
49
- raise Exception(
58
+ raise ValueError(
50
59
  "cannot honour any timeout other than 0 in an asynchronous function"
51
60
  )
52
-
53
61
  if self.task.done():
54
62
  try:
55
63
  return self.task.exception()
@@ -69,22 +77,6 @@ class AsyncStatus(Status):
69
77
  and self.task.exception() is None
70
78
  )
71
79
 
72
- def watch(self, watcher: Callable):
73
- """Add watcher to the list of interested parties.
74
-
75
- Arguments as per Bluesky :external+bluesky:meth:`watch` protocol.
76
- """
77
- if self._watchers is not None:
78
- self._watchers.append(watcher)
79
-
80
- @classmethod
81
- def wrap(cls, f: Callable[[T], Coroutine]) -> Callable[[T], "AsyncStatus"]:
82
- @functools.wraps(f)
83
- def wrap_f(self) -> AsyncStatus:
84
- return AsyncStatus(f(self))
85
-
86
- return wrap_f
87
-
88
80
  def __repr__(self) -> str:
89
81
  if self.done:
90
82
  if e := self.exception():
@@ -96,3 +88,72 @@ class AsyncStatus(Status):
96
88
  return f"<{type(self).__name__}, task: {self.task.get_coro()}, {status}>"
97
89
 
98
90
  __str__ = __repr__
91
+
92
+
93
+ class AsyncStatus(AsyncStatusBase):
94
+ @classmethod
95
+ def wrap(cls: Type[AS], f: Callable[P, Awaitable]) -> Callable[P, AS]:
96
+ @functools.wraps(f)
97
+ def wrap_f(*args: P.args, **kwargs: P.kwargs) -> AS:
98
+ # We can't type this more properly because Concatenate/ParamSpec doesn't
99
+ # yet support keywords
100
+ # https://peps.python.org/pep-0612/#concatenating-keyword-parameters
101
+ timeout = kwargs.get("timeout")
102
+ assert isinstance(timeout, SupportsFloat) or timeout is None
103
+ return cls(f(*args, **kwargs), timeout=timeout)
104
+
105
+ # type is actually functools._Wrapped[P, Awaitable, P, AS]
106
+ # but functools._Wrapped is not necessarily available
107
+ return cast(Callable[P, AS], wrap_f)
108
+
109
+
110
+ class WatchableAsyncStatus(AsyncStatusBase, Generic[T]):
111
+ """Convert AsyncIterator of WatcherUpdates to bluesky Status interface."""
112
+
113
+ def __init__(
114
+ self,
115
+ iterator: AsyncIterator[WatcherUpdate[T]],
116
+ timeout: SupportsFloat | None = None,
117
+ ):
118
+ self._watchers: list[Watcher] = []
119
+ self._start = time.monotonic()
120
+ self._last_update: WatcherUpdate[T] | None = None
121
+ super().__init__(self._notify_watchers_from(iterator), timeout)
122
+
123
+ async def _notify_watchers_from(self, iterator: AsyncIterator[WatcherUpdate[T]]):
124
+ async for update in iterator:
125
+ self._last_update = (
126
+ update
127
+ if update.time_elapsed is not None
128
+ else replace(update, time_elapsed=time.monotonic() - self._start)
129
+ )
130
+ for watcher in self._watchers:
131
+ self._update_watcher(watcher, self._last_update)
132
+
133
+ def _update_watcher(self, watcher: Watcher, update: WatcherUpdate[T]):
134
+ vals = asdict(
135
+ update, dict_factory=lambda d: {k: v for k, v in d if v is not None}
136
+ )
137
+ watcher(**vals)
138
+
139
+ def watch(self, watcher: Watcher):
140
+ self._watchers.append(watcher)
141
+ if self._last_update:
142
+ self._update_watcher(watcher, self._last_update)
143
+
144
+ @classmethod
145
+ def wrap(
146
+ cls: Type[WAS],
147
+ f: Callable[P, AsyncIterator[WatcherUpdate[T]]],
148
+ ) -> Callable[P, WAS]:
149
+ """Wrap an AsyncIterator in a WatchableAsyncStatus. If it takes
150
+ 'timeout' as an argument, this must be a float or None, and it
151
+ will be propagated to the status."""
152
+
153
+ @functools.wraps(f)
154
+ def wrap_f(*args: P.args, **kwargs: P.kwargs) -> WAS:
155
+ timeout = kwargs.get("timeout")
156
+ assert isinstance(timeout, SupportsFloat) or timeout is None
157
+ return cls(f(*args, **kwargs), timeout=timeout)
158
+
159
+ return cast(Callable[P, WAS], wrap_f)
@@ -31,9 +31,9 @@ from bluesky.protocols import (
31
31
 
32
32
  from ophyd_async.protocols import AsyncConfigurable, AsyncReadable
33
33
 
34
- from .async_status import AsyncStatus
34
+ from .async_status import AsyncStatus, WatchableAsyncStatus
35
35
  from .device import Device
36
- from .utils import DEFAULT_TIMEOUT, merge_gathered_dicts
36
+ from .utils import DEFAULT_TIMEOUT, WatcherUpdate, merge_gathered_dicts
37
37
 
38
38
  T = TypeVar("T")
39
39
 
@@ -188,7 +188,7 @@ class StandardDetector(
188
188
  self._trigger_info: Optional[TriggerInfo] = None
189
189
  # For kickoff
190
190
  self._watchers: List[Callable] = []
191
- self._fly_status: Optional[AsyncStatus] = None
191
+ self._fly_status: Optional[WatchableAsyncStatus] = None
192
192
  self._fly_start: float
193
193
 
194
194
  self._intial_frame: int
@@ -292,43 +292,37 @@ class StandardDetector(
292
292
  f"Detector {self.controller} needs at least {required}s deadtime, "
293
293
  f"but trigger logic provides only {self._trigger_info.deadtime}s"
294
294
  )
295
-
296
295
  self._arm_status = await self.controller.arm(
297
296
  num=self._trigger_info.num,
298
297
  trigger=self._trigger_info.trigger,
299
298
  exposure=self._trigger_info.livetime,
300
299
  )
301
-
302
- @AsyncStatus.wrap
303
- async def kickoff(self) -> None:
304
- self._fly_status = AsyncStatus(self._fly(), self._watchers)
305
300
  self._fly_start = time.monotonic()
306
301
 
307
- async def _fly(self) -> None:
308
- await self._observe_writer_indicies(self._last_frame)
309
-
310
- async def _observe_writer_indicies(self, end_observation: int):
302
+ @AsyncStatus.wrap
303
+ async def kickoff(self):
304
+ if not self._arm_status:
305
+ raise Exception("Detector not armed!")
306
+
307
+ @WatchableAsyncStatus.wrap
308
+ async def complete(self):
309
+ assert self._arm_status, "Prepare not run"
310
+ assert self._trigger_info
311
311
  async for index in self.writer.observe_indices_written(
312
312
  self._frame_writing_timeout
313
313
  ):
314
- for watcher in self._watchers:
315
- watcher(
316
- name=self.name,
317
- current=index,
318
- initial=self._initial_frame,
319
- target=end_observation,
320
- unit="",
321
- precision=0,
322
- time_elapsed=time.monotonic() - self._fly_start,
323
- )
324
- if index >= end_observation:
314
+ yield WatcherUpdate(
315
+ name=self.name,
316
+ current=index,
317
+ initial=self._initial_frame,
318
+ target=self._trigger_info.num,
319
+ unit="",
320
+ precision=0,
321
+ time_elapsed=time.monotonic() - self._fly_start,
322
+ )
323
+ if index >= self._trigger_info.num:
325
324
  break
326
325
 
327
- @AsyncStatus.wrap
328
- async def complete(self) -> AsyncStatus:
329
- assert self._fly_status, "Kickoff not run"
330
- return await self._fly_status
331
-
332
326
  async def describe_collect(self) -> Dict[str, DataKey]:
333
327
  return self._describe
334
328
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import sys
6
7
  from functools import cached_property
7
8
  from logging import LoggerAdapter, getLogger
@@ -32,6 +33,9 @@ class Device(HasName):
32
33
  _name: str = ""
33
34
  #: The parent Device if it exists
34
35
  parent: Optional[Device] = None
36
+ # None if connect hasn't started, a Task if it has
37
+ _connect_task: Optional[asyncio.Task] = None
38
+ _connect_mock_arg: bool = False
35
39
 
36
40
  def __init__(self, name: str = "") -> None:
37
41
  self.set_name(name)
@@ -71,7 +75,12 @@ class Device(HasName):
71
75
  child.set_name(child_name)
72
76
  child.parent = self
73
77
 
74
- async def connect(self, mock: bool = False, timeout: float = DEFAULT_TIMEOUT):
78
+ async def connect(
79
+ self,
80
+ mock: bool = False,
81
+ timeout: float = DEFAULT_TIMEOUT,
82
+ force_reconnect: bool = False,
83
+ ):
75
84
  """Connect self and all child Devices.
76
85
 
77
86
  Contains a timeout that gets propagated to child.connect methods.
@@ -83,12 +92,26 @@ class Device(HasName):
83
92
  timeout:
84
93
  Time to wait before failing with a TimeoutError.
85
94
  """
86
- coros = {
87
- name: child_device.connect(mock=mock, timeout=timeout)
88
- for name, child_device in self.children()
89
- }
90
- if coros:
91
- await wait_for_connection(**coros)
95
+ # If previous connect with same args has started and not errored, can use it
96
+ can_use_previous_connect = (
97
+ self._connect_task
98
+ and not (self._connect_task.done() and self._connect_task.exception())
99
+ and self._connect_mock_arg == mock
100
+ )
101
+ if force_reconnect or not can_use_previous_connect:
102
+ # Kick off a connection
103
+ coros = {
104
+ name: child_device.connect(
105
+ mock, timeout=timeout, force_reconnect=force_reconnect
106
+ )
107
+ for name, child_device in self.children()
108
+ }
109
+ self._connect_task = asyncio.create_task(wait_for_connection(**coros))
110
+ self._connect_mock_arg = mock
111
+
112
+ assert self._connect_task, "Connect task not created, this shouldn't happen"
113
+ # Wait for it to complete
114
+ await self._connect_task
92
115
 
93
116
 
94
117
  VT = TypeVar("VT", bound=Device)
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
+ from functools import cached_property
2
3
  from typing import Optional, Type
3
- from unittest.mock import MagicMock
4
+ from unittest.mock import Mock
4
5
 
5
6
  from bluesky.protocols import Descriptor, Reading
6
7
 
@@ -36,51 +37,49 @@ class MockSignalBackend(SignalBackend):
36
37
  else:
37
38
  self.soft_backend = initial_backend
38
39
 
39
- self.mock = MagicMock()
40
-
41
- self.put_proceeds = asyncio.Event()
42
- self.put_proceeds.set()
43
-
44
40
  def source(self, name: str) -> str:
45
- self.mock.source(name)
46
41
  if self.initial_backend:
47
42
  return f"mock+{self.initial_backend.source(name)}"
48
43
  return f"mock+{name}"
49
44
 
50
45
  async def connect(self, timeout: float = DEFAULT_TIMEOUT) -> None:
51
- self.mock.connect(timeout=timeout)
46
+ pass
47
+
48
+ @cached_property
49
+ def put_mock(self) -> Mock:
50
+ return Mock(name="put")
51
+
52
+ @cached_property
53
+ def put_proceeds(self) -> asyncio.Event:
54
+ put_proceeds = asyncio.Event()
55
+ put_proceeds.set()
56
+ return put_proceeds
52
57
 
53
58
  async def put(self, value: Optional[T], wait=True, timeout=None):
54
- self.mock.put(value, wait=wait, timeout=timeout)
59
+ self.put_mock(value, wait=wait, timeout=timeout)
55
60
  await self.soft_backend.put(value, wait=wait, timeout=timeout)
56
61
 
57
62
  if wait:
58
63
  await asyncio.wait_for(self.put_proceeds.wait(), timeout=timeout)
59
64
 
60
65
  def set_value(self, value: T):
61
- self.mock.set_value(value)
62
66
  self.soft_backend.set_value(value)
63
67
 
64
68
  async def get_descriptor(self, source: str) -> Descriptor:
65
- self.mock.get_descriptor(source)
66
69
  return await self.soft_backend.get_descriptor(source)
67
70
 
68
71
  async def get_reading(self) -> Reading:
69
- self.mock.get_reading()
70
72
  return await self.soft_backend.get_reading()
71
73
 
72
74
  async def get_value(self) -> T:
73
- self.mock.get_value()
74
75
  return await self.soft_backend.get_value()
75
76
 
76
77
  async def get_setpoint(self) -> T:
77
78
  """For a soft signal, the setpoint and readback values are the same."""
78
- self.mock.get_setpoint()
79
79
  return await self.soft_backend.get_setpoint()
80
80
 
81
81
  async def get_datakey(self, source: str) -> Descriptor:
82
82
  return await self.soft_backend.get_datakey(source)
83
83
 
84
84
  def set_callback(self, callback: Optional[ReadingValueCallback[T]]) -> None:
85
- self.mock.set_callback(callback)
86
85
  self.soft_backend.set_callback(callback)
@@ -1,6 +1,6 @@
1
1
  from contextlib import asynccontextmanager, contextmanager
2
2
  from typing import Any, Callable, Generator, Iterable, Iterator, List
3
- from unittest.mock import ANY
3
+ from unittest.mock import ANY, Mock
4
4
 
5
5
  from ophyd_async.core.signal import Signal
6
6
  from ophyd_async.core.utils import T
@@ -43,12 +43,12 @@ async def mock_puts_blocked(*signals: List[Signal]):
43
43
 
44
44
  def assert_mock_put_called_with(signal: Signal, value: Any, wait=ANY, timeout=ANY):
45
45
  backend = _get_mock_signal_backend(signal)
46
- backend.mock.put.assert_called_with(value, wait=wait, timeout=timeout)
46
+ backend.put_mock.assert_called_with(value, wait=wait, timeout=timeout)
47
47
 
48
48
 
49
49
  def reset_mock_put_calls(signal: Signal):
50
50
  backend = _get_mock_signal_backend(signal)
51
- backend.mock.put.reset_mock()
51
+ backend.put_mock.reset_mock()
52
52
 
53
53
 
54
54
  class _SetValuesIterator:
@@ -122,9 +122,9 @@ def set_mock_values(
122
122
 
123
123
 
124
124
  @contextmanager
125
- def _unset_side_effect_cm(mock):
125
+ def _unset_side_effect_cm(put_mock: Mock):
126
126
  yield
127
- mock.put.side_effect = None
127
+ put_mock.side_effect = None
128
128
 
129
129
 
130
130
  # linting isn't smart enought to realize @contextmanager will give use a
@@ -145,5 +145,5 @@ def callback_on_mock_put(
145
145
  The callback to call when the backend is put to during the context.
146
146
  """
147
147
  backend = _get_mock_signal_backend(signal)
148
- backend.mock.put.side_effect = callback
149
- return _unset_side_effect_cm(backend.mock)
148
+ backend.put_mock.side_effect = callback
149
+ return _unset_side_effect_cm(backend.put_mock)
@@ -21,6 +21,7 @@ from bluesky.protocols import (
21
21
  Location,
22
22
  Movable,
23
23
  Reading,
24
+ Status,
24
25
  Subscribable,
25
26
  )
26
27
 
@@ -64,7 +65,9 @@ class Signal(Device, Generic[T]):
64
65
  self._initial_backend = self._backend = backend
65
66
  super().__init__(name)
66
67
 
67
- async def connect(self, mock=False, timeout=DEFAULT_TIMEOUT):
68
+ async def connect(
69
+ self, mock=False, timeout=DEFAULT_TIMEOUT, force_reconnect: bool = False
70
+ ):
68
71
  if mock and not isinstance(self._backend, MockSignalBackend):
69
72
  # Using a soft backend, look to the initial value
70
73
  self._backend = MockSignalBackend(
@@ -278,6 +281,19 @@ def soft_signal_r_and_setter(
278
281
  return (signal, backend.set_value)
279
282
 
280
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
+ )
295
+
296
+
281
297
  async def assert_value(signal: SignalR[T], value: Any) -> None:
282
298
  """Assert a signal's value and compare it an expected signal.
283
299
 
@@ -294,11 +310,14 @@ async def assert_value(signal: SignalR[T], value: Any) -> None:
294
310
  await assert_value(signal, value)
295
311
 
296
312
  """
297
- 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
+ )
298
317
 
299
318
 
300
319
  async def assert_reading(
301
- readable: AsyncReadable, reading: Mapping[str, Reading]
320
+ readable: AsyncReadable, expected_reading: Mapping[str, Reading]
302
321
  ) -> None:
303
322
  """Assert readings from readable.
304
323
 
@@ -316,7 +335,10 @@ async def assert_reading(
316
335
  await assert_reading(readable, reading)
317
336
 
318
337
  """
319
- 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
+ )
320
342
 
321
343
 
322
344
  async def assert_configuration(
@@ -339,7 +361,10 @@ async def assert_configuration(
339
361
  await assert_configuration(configurable configuration)
340
362
 
341
363
  """
342
- 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
+ )
343
368
 
344
369
 
345
370
  def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int):
@@ -359,11 +384,18 @@ def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int):
359
384
  assert_emitted(docs, start=1, descriptor=1,
360
385
  resource=1, datum=1, event=1, stop=1)
361
386
  """
362
- assert list(docs) == list(numbers)
363
- assert {name: len(d) for name, d in docs.items()} == numbers
364
-
365
-
366
- 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]:
367
399
  """Subscribe to the value of a signal so it can be iterated from.
368
400
 
369
401
  Parameters
@@ -371,6 +403,8 @@ async def observe_value(signal: SignalR[T], timeout=None) -> AsyncGenerator[T, N
371
403
  signal:
372
404
  Call subscribe_value on this at the start, and clear_sub on it at the
373
405
  end
406
+ done_status:
407
+ If this status is complete, stop observing and make the iterator return.
374
408
 
375
409
  Notes
376
410
  -----
@@ -379,7 +413,10 @@ async def observe_value(signal: SignalR[T], timeout=None) -> AsyncGenerator[T, N
379
413
  async for value in observe_value(sig):
380
414
  do_something_with(value)
381
415
  """
382
- q: asyncio.Queue[T] = asyncio.Queue()
416
+
417
+ class StatusIsDone: ...
418
+
419
+ q: asyncio.Queue[T | StatusIsDone] = asyncio.Queue()
383
420
  if timeout is None:
384
421
  get_value = q.get
385
422
  else:
@@ -387,10 +424,17 @@ async def observe_value(signal: SignalR[T], timeout=None) -> AsyncGenerator[T, N
387
424
  async def get_value():
388
425
  return await asyncio.wait_for(q.get(), timeout)
389
426
 
427
+ if done_status is not None:
428
+ done_status.add_callback(lambda _: q.put_nowait(StatusIsDone()))
429
+
390
430
  signal.subscribe_value(q.put_nowait)
391
431
  try:
392
432
  while True:
393
- yield await get_value()
433
+ item = await get_value()
434
+ if not isinstance(item, StatusIsDone):
435
+ yield item
436
+ else:
437
+ break
394
438
  finally:
395
439
  signal.clear_sub(q.put_nowait)
396
440
 
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
 
@@ -2,7 +2,7 @@ from typing import get_args
2
2
 
3
3
  from bluesky.protocols import HasHints, Hints
4
4
 
5
- from ophyd_async.core import DirectoryProvider, StandardDetector, TriggerInfo
5
+ from ophyd_async.core import DirectoryProvider, StandardDetector
6
6
  from ophyd_async.epics.areadetector.controllers.aravis_controller import (
7
7
  AravisController,
8
8
  )
@@ -45,10 +45,6 @@ class AravisDetector(StandardDetector, HasHints):
45
45
  name=name,
46
46
  )
47
47
 
48
- async def _prepare(self, value: TriggerInfo) -> None:
49
- await self.drv.fetch_deadtime()
50
- await super()._prepare(value)
51
-
52
48
  def get_external_trigger_gpio(self):
53
49
  return self._controller.gpio_number
54
50
 
@@ -14,6 +14,11 @@ from ophyd_async.epics.areadetector.drivers.aravis_driver import (
14
14
  )
15
15
  from ophyd_async.epics.areadetector.utils import ImageMode, stop_busy_record
16
16
 
17
+ # The deadtime of an ADaravis controller varies depending on the exact model of camera.
18
+ # Ideally we would maximize performance by dynamically retrieving the deadtime at
19
+ # runtime. See https://github.com/bluesky/ophyd-async/issues/308
20
+ _HIGHEST_POSSIBLE_DEADTIME = 1961e-6
21
+
17
22
 
18
23
  class AravisController(DetectorControl):
19
24
  GPIO_NUMBER = Literal[1, 2, 3, 4]
@@ -23,7 +28,7 @@ class AravisController(DetectorControl):
23
28
  self.gpio_number = gpio_number
24
29
 
25
30
  def get_deadtime(self, exposure: float) -> float:
26
- return self._drv.dead_time or 0
31
+ return _HIGHEST_POSSIBLE_DEADTIME
27
32
 
28
33
  async def arm(
29
34
  self,