ophyd-async 0.1.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 (94) hide show
  1. ophyd_async/__init__.py +1 -4
  2. ophyd_async/_version.py +2 -2
  3. ophyd_async/core/__init__.py +91 -19
  4. ophyd_async/core/_providers.py +68 -0
  5. ophyd_async/core/async_status.py +90 -42
  6. ophyd_async/core/detector.py +341 -0
  7. ophyd_async/core/device.py +226 -0
  8. ophyd_async/core/device_save_loader.py +286 -0
  9. ophyd_async/core/flyer.py +85 -0
  10. ophyd_async/core/mock_signal_backend.py +82 -0
  11. ophyd_async/core/mock_signal_utils.py +145 -0
  12. ophyd_async/core/{_device/_signal/signal.py → signal.py} +249 -61
  13. ophyd_async/core/{_device/_backend/signal_backend.py → signal_backend.py} +12 -5
  14. ophyd_async/core/{_device/_backend/sim_signal_backend.py → soft_signal_backend.py} +54 -48
  15. ophyd_async/core/standard_readable.py +261 -0
  16. ophyd_async/core/utils.py +127 -30
  17. ophyd_async/epics/_backend/_aioca.py +62 -43
  18. ophyd_async/epics/_backend/_p4p.py +100 -52
  19. ophyd_async/epics/_backend/common.py +25 -0
  20. ophyd_async/epics/areadetector/__init__.py +16 -15
  21. ophyd_async/epics/areadetector/aravis.py +63 -0
  22. ophyd_async/epics/areadetector/controllers/__init__.py +5 -0
  23. ophyd_async/epics/areadetector/controllers/ad_sim_controller.py +52 -0
  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 +61 -0
  27. ophyd_async/epics/areadetector/controllers/vimba_controller.py +66 -0
  28. ophyd_async/epics/areadetector/drivers/__init__.py +21 -0
  29. ophyd_async/epics/areadetector/drivers/ad_base.py +107 -0
  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 +21 -0
  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 +18 -10
  37. ophyd_async/epics/areadetector/utils.py +91 -13
  38. ophyd_async/epics/areadetector/vimba.py +43 -0
  39. ophyd_async/epics/areadetector/writers/__init__.py +5 -0
  40. ophyd_async/epics/areadetector/writers/_hdfdataset.py +10 -0
  41. ophyd_async/epics/areadetector/writers/_hdffile.py +54 -0
  42. ophyd_async/epics/areadetector/writers/hdf_writer.py +142 -0
  43. ophyd_async/epics/areadetector/writers/nd_file_hdf.py +40 -0
  44. ophyd_async/epics/areadetector/writers/nd_plugin.py +38 -0
  45. ophyd_async/epics/demo/__init__.py +78 -51
  46. ophyd_async/epics/demo/demo_ad_sim_detector.py +35 -0
  47. ophyd_async/epics/motion/motor.py +67 -52
  48. ophyd_async/epics/pvi/__init__.py +3 -0
  49. ophyd_async/epics/pvi/pvi.py +318 -0
  50. ophyd_async/epics/signal/__init__.py +8 -3
  51. ophyd_async/epics/signal/signal.py +27 -10
  52. ophyd_async/log.py +130 -0
  53. ophyd_async/panda/__init__.py +24 -7
  54. ophyd_async/panda/_common_blocks.py +49 -0
  55. ophyd_async/panda/_hdf_panda.py +48 -0
  56. ophyd_async/panda/_panda_controller.py +37 -0
  57. ophyd_async/panda/_table.py +158 -0
  58. ophyd_async/panda/_trigger.py +39 -0
  59. ophyd_async/panda/_utils.py +15 -0
  60. ophyd_async/panda/writers/__init__.py +3 -0
  61. ophyd_async/panda/writers/_hdf_writer.py +220 -0
  62. ophyd_async/panda/writers/_panda_hdf_file.py +58 -0
  63. ophyd_async/plan_stubs/__init__.py +13 -0
  64. ophyd_async/plan_stubs/ensure_connected.py +22 -0
  65. ophyd_async/plan_stubs/fly.py +149 -0
  66. ophyd_async/protocols.py +126 -0
  67. ophyd_async/sim/__init__.py +11 -0
  68. ophyd_async/sim/demo/__init__.py +3 -0
  69. ophyd_async/sim/demo/sim_motor.py +103 -0
  70. ophyd_async/sim/pattern_generator.py +318 -0
  71. ophyd_async/sim/sim_pattern_detector_control.py +55 -0
  72. ophyd_async/sim/sim_pattern_detector_writer.py +34 -0
  73. ophyd_async/sim/sim_pattern_generator.py +37 -0
  74. {ophyd_async-0.1.0.dist-info → ophyd_async-0.3.0.dist-info}/METADATA +35 -67
  75. ophyd_async-0.3.0.dist-info/RECORD +86 -0
  76. {ophyd_async-0.1.0.dist-info → ophyd_async-0.3.0.dist-info}/WHEEL +1 -1
  77. ophyd_async/core/_device/__init__.py +0 -0
  78. ophyd_async/core/_device/_backend/__init__.py +0 -0
  79. ophyd_async/core/_device/_signal/__init__.py +0 -0
  80. ophyd_async/core/_device/device.py +0 -60
  81. ophyd_async/core/_device/device_collector.py +0 -121
  82. ophyd_async/core/_device/device_vector.py +0 -14
  83. ophyd_async/core/_device/standard_readable.py +0 -72
  84. ophyd_async/epics/areadetector/ad_driver.py +0 -18
  85. ophyd_async/epics/areadetector/directory_provider.py +0 -18
  86. ophyd_async/epics/areadetector/hdf_streamer_det.py +0 -167
  87. ophyd_async/epics/areadetector/nd_file_hdf.py +0 -22
  88. ophyd_async/epics/areadetector/nd_plugin.py +0 -13
  89. ophyd_async/epics/signal/pvi_get.py +0 -22
  90. ophyd_async/panda/panda.py +0 -332
  91. ophyd_async-0.1.0.dist-info/RECORD +0 -45
  92. {ophyd_async-0.1.0.dist-info → ophyd_async-0.3.0.dist-info}/LICENSE +0 -0
  93. {ophyd_async-0.1.0.dist-info → ophyd_async-0.3.0.dist-info}/entry_points.txt +0 -0
  94. {ophyd_async-0.1.0.dist-info → ophyd_async-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,341 @@
1
+ """Module which defines abstract classes to work with detectors"""
2
+
3
+ import asyncio
4
+ import time
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import (
9
+ AsyncGenerator,
10
+ AsyncIterator,
11
+ Callable,
12
+ Dict,
13
+ Generic,
14
+ List,
15
+ Optional,
16
+ Sequence,
17
+ TypeVar,
18
+ )
19
+
20
+ from bluesky.protocols import (
21
+ Collectable,
22
+ DataKey,
23
+ Flyable,
24
+ Preparable,
25
+ Reading,
26
+ Stageable,
27
+ StreamAsset,
28
+ Triggerable,
29
+ WritesStreamAssets,
30
+ )
31
+
32
+ from ophyd_async.protocols import AsyncConfigurable, AsyncReadable
33
+
34
+ from .async_status import AsyncStatus, WatchableAsyncStatus
35
+ from .device import Device
36
+ from .utils import DEFAULT_TIMEOUT, WatcherUpdate, merge_gathered_dicts
37
+
38
+ T = TypeVar("T")
39
+
40
+
41
+ class DetectorTrigger(str, Enum):
42
+ """Type of mechanism for triggering a detector to take frames"""
43
+
44
+ #: Detector generates internal trigger for given rate
45
+ internal = "internal"
46
+ #: Expect a series of arbitrary length trigger signals
47
+ edge_trigger = "edge_trigger"
48
+ #: Expect a series of constant width external gate signals
49
+ constant_gate = "constant_gate"
50
+ #: Expect a series of variable width external gate signals
51
+ variable_gate = "variable_gate"
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class TriggerInfo:
56
+ """Minimal set of information required to setup triggering on a detector"""
57
+
58
+ #: Number of triggers that will be sent
59
+ num: int
60
+ #: Sort of triggers that will be sent
61
+ trigger: DetectorTrigger
62
+ #: What is the minimum deadtime between triggers
63
+ deadtime: float
64
+ #: What is the maximum high time of the triggers
65
+ livetime: float
66
+
67
+
68
+ class DetectorControl(ABC):
69
+ """
70
+ Classes implementing this interface should hold the logic for
71
+ arming and disarming a detector
72
+ """
73
+
74
+ @abstractmethod
75
+ def get_deadtime(self, exposure: float) -> float:
76
+ """For a given exposure, how long should the time between exposures be"""
77
+
78
+ @abstractmethod
79
+ async def arm(
80
+ self,
81
+ num: int,
82
+ trigger: DetectorTrigger = DetectorTrigger.internal,
83
+ exposure: Optional[float] = None,
84
+ ) -> AsyncStatus:
85
+ """
86
+ Arm detector, do all necessary steps to prepare detector for triggers.
87
+
88
+ Args:
89
+ num: Expected number of frames
90
+ trigger: Type of trigger for which to prepare the detector. Defaults to
91
+ DetectorTrigger.internal.
92
+ exposure: Exposure time with which to set up the detector. Defaults to None
93
+ if not applicable or the detector is expected to use its previously-set
94
+ exposure time.
95
+
96
+ Returns:
97
+ AsyncStatus: Status representing the arm operation. This function returning
98
+ represents the start of the arm. The returned status completing means
99
+ the detector is now armed.
100
+ """
101
+
102
+ @abstractmethod
103
+ async def disarm(self):
104
+ """Disarm the detector, return detector to an idle state"""
105
+
106
+
107
+ class DetectorWriter(ABC):
108
+ """Logic for making a detector write data to somewhere persistent
109
+ (e.g. an HDF5 file)"""
110
+
111
+ @abstractmethod
112
+ async def open(self, multiplier: int = 1) -> Dict[str, DataKey]:
113
+ """Open writer and wait for it to be ready for data.
114
+
115
+ Args:
116
+ multiplier: Each StreamDatum index corresponds to this many
117
+ written exposures
118
+
119
+ Returns:
120
+ Output for ``describe()``
121
+ """
122
+
123
+ @abstractmethod
124
+ def observe_indices_written(
125
+ self, timeout=DEFAULT_TIMEOUT
126
+ ) -> AsyncGenerator[int, None]:
127
+ """Yield the index of each frame (or equivalent data point) as it is written"""
128
+
129
+ @abstractmethod
130
+ async def get_indices_written(self) -> int:
131
+ """Get the number of indices written"""
132
+
133
+ @abstractmethod
134
+ def collect_stream_docs(self, indices_written: int) -> AsyncIterator[StreamAsset]:
135
+ """Create Stream docs up to given number written"""
136
+
137
+ @abstractmethod
138
+ async def close(self) -> None:
139
+ """Close writer, blocks until I/O is complete"""
140
+
141
+
142
+ class StandardDetector(
143
+ Device,
144
+ Stageable,
145
+ AsyncConfigurable,
146
+ AsyncReadable,
147
+ Triggerable,
148
+ Preparable,
149
+ Flyable,
150
+ Collectable,
151
+ WritesStreamAssets,
152
+ Generic[T],
153
+ ):
154
+ """
155
+ Useful detector base class for step and fly scanning detectors.
156
+ Aggregates controller and writer logic together.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ controller: DetectorControl,
162
+ writer: DetectorWriter,
163
+ config_sigs: Sequence[AsyncReadable] = (),
164
+ name: str = "",
165
+ writer_timeout: float = DEFAULT_TIMEOUT,
166
+ ) -> None:
167
+ """
168
+ Constructor
169
+
170
+ Args:
171
+ controller: Logic for arming and disarming the detector
172
+ writer: Logic for making the detector write persistent data
173
+ config_sigs: Signals to read when describe and read
174
+ configuration are called. Defaults to ().
175
+ name: Device name. Defaults to "".
176
+ writer_timeout: Timeout for frame writing to start, if the
177
+ timeout is reached, ophyd-async assumes the detector
178
+ has a problem and raises an error.
179
+ Defaults to DEFAULT_TIMEOUT.
180
+ """
181
+ self._controller = controller
182
+ self._writer = writer
183
+ self._describe: Dict[str, DataKey] = {}
184
+ self._config_sigs = list(config_sigs)
185
+ self._frame_writing_timeout = writer_timeout
186
+ # For prepare
187
+ self._arm_status: Optional[AsyncStatus] = None
188
+ self._trigger_info: Optional[TriggerInfo] = None
189
+ # For kickoff
190
+ self._watchers: List[Callable] = []
191
+ self._fly_status: Optional[WatchableAsyncStatus] = None
192
+ self._fly_start: float
193
+
194
+ self._intial_frame: int
195
+ self._last_frame: int
196
+ super().__init__(name)
197
+
198
+ @property
199
+ def controller(self) -> DetectorControl:
200
+ return self._controller
201
+
202
+ @property
203
+ def writer(self) -> DetectorWriter:
204
+ return self._writer
205
+
206
+ @AsyncStatus.wrap
207
+ async def stage(self) -> None:
208
+ # Disarm the detector, stop filewriting, and open file for writing.
209
+ await self._check_config_sigs()
210
+ await asyncio.gather(self.writer.close(), self.controller.disarm())
211
+ self._describe = await self.writer.open()
212
+
213
+ async def _check_config_sigs(self):
214
+ """Checks configuration signals are named and connected."""
215
+ for signal in self._config_sigs:
216
+ if signal.name == "":
217
+ raise Exception(
218
+ "config signal must be named before it is passed to the detector"
219
+ )
220
+ try:
221
+ await signal.get_value()
222
+ except NotImplementedError:
223
+ raise Exception(
224
+ f"config signal {signal._name} must be connected before it is "
225
+ + "passed to the detector"
226
+ )
227
+
228
+ @AsyncStatus.wrap
229
+ async def unstage(self) -> None:
230
+ # Stop data writing.
231
+ await self.writer.close()
232
+
233
+ async def read_configuration(self) -> Dict[str, Reading]:
234
+ return await merge_gathered_dicts(sig.read() for sig in self._config_sigs)
235
+
236
+ async def describe_configuration(self) -> Dict[str, DataKey]:
237
+ return await merge_gathered_dicts(sig.describe() for sig in self._config_sigs)
238
+
239
+ async def read(self) -> Dict[str, Reading]:
240
+ # All data is in StreamResources, not Events, so nothing to output here
241
+ return {}
242
+
243
+ async def describe(self) -> Dict[str, DataKey]:
244
+ return self._describe
245
+
246
+ @AsyncStatus.wrap
247
+ async def trigger(self) -> None:
248
+ # Arm the detector and wait for it to finish.
249
+ indices_written = await self.writer.get_indices_written()
250
+ written_status = await self.controller.arm(
251
+ num=1,
252
+ trigger=DetectorTrigger.internal,
253
+ )
254
+ await written_status
255
+ end_observation = indices_written + 1
256
+
257
+ async for index in self.writer.observe_indices_written(
258
+ self._frame_writing_timeout
259
+ ):
260
+ if index >= end_observation:
261
+ break
262
+
263
+ def prepare(
264
+ self,
265
+ value: T,
266
+ ) -> AsyncStatus:
267
+ # Just arm detector for the time being
268
+ return AsyncStatus(self._prepare(value))
269
+
270
+ async def _prepare(self, value: T) -> None:
271
+ """
272
+ Arm detector.
273
+
274
+ Prepare the detector with trigger information. This is determined at and passed
275
+ in from the plan level.
276
+
277
+ This currently only prepares detectors for flyscans and stepscans just use the
278
+ trigger information determined in trigger.
279
+
280
+ To do: Unify prepare to be use for both fly and step scans.
281
+
282
+ Args:
283
+ value: TriggerInfo describing how to trigger the detector
284
+ """
285
+ assert type(value) is TriggerInfo
286
+ self._trigger_info = value
287
+ self._initial_frame = await self.writer.get_indices_written()
288
+ self._last_frame = self._initial_frame + self._trigger_info.num
289
+
290
+ required = self.controller.get_deadtime(self._trigger_info.livetime)
291
+ assert required <= self._trigger_info.deadtime, (
292
+ f"Detector {self.controller} needs at least {required}s deadtime, "
293
+ f"but trigger logic provides only {self._trigger_info.deadtime}s"
294
+ )
295
+ self._arm_status = await self.controller.arm(
296
+ num=self._trigger_info.num,
297
+ trigger=self._trigger_info.trigger,
298
+ exposure=self._trigger_info.livetime,
299
+ )
300
+ self._fly_start = time.monotonic()
301
+
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
+ async for index in self.writer.observe_indices_written(
312
+ self._frame_writing_timeout
313
+ ):
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:
324
+ break
325
+
326
+ async def describe_collect(self) -> Dict[str, DataKey]:
327
+ return self._describe
328
+
329
+ async def collect_asset_docs(
330
+ self, index: Optional[int] = None
331
+ ) -> AsyncIterator[StreamAsset]:
332
+ # Collect stream datum documents for all indices written.
333
+ # The index is optional, and provided for fly scans, however this needs to be
334
+ # retrieved for step scans.
335
+ if index is None:
336
+ index = await self.writer.get_indices_written()
337
+ async for doc in self.writer.collect_stream_docs(index):
338
+ yield doc
339
+
340
+ async def get_index(self) -> int:
341
+ return await self.writer.get_indices_written()
@@ -0,0 +1,226 @@
1
+ """Base device"""
2
+
3
+ import asyncio
4
+ import sys
5
+ from functools import cached_property
6
+ from logging import LoggerAdapter, getLogger
7
+ from typing import (
8
+ Any,
9
+ Coroutine,
10
+ Dict,
11
+ Generator,
12
+ Iterator,
13
+ Optional,
14
+ Set,
15
+ Tuple,
16
+ TypeVar,
17
+ )
18
+
19
+ from bluesky.protocols import HasName
20
+ from bluesky.run_engine import call_in_bluesky_event_loop
21
+
22
+ from .utils import DEFAULT_TIMEOUT, NotConnected, wait_for_connection
23
+
24
+
25
+ class Device(HasName):
26
+ """Common base class for all Ophyd Async Devices.
27
+
28
+ By default, names and connects all Device children.
29
+ """
30
+
31
+ _name: str = ""
32
+ #: The parent Device if it exists
33
+ parent: Optional["Device"] = None
34
+ # None if connect hasn't started, a Task if it has
35
+ _connect_task: Optional[asyncio.Task] = None
36
+ _connect_mock_arg: bool = False
37
+
38
+ def __init__(self, name: str = "") -> None:
39
+ self.set_name(name)
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ """Return the name of the Device"""
44
+ return self._name
45
+
46
+ @cached_property
47
+ def log(self):
48
+ return LoggerAdapter(
49
+ getLogger("ophyd_async.devices"), {"ophyd_async_device_name": self.name}
50
+ )
51
+
52
+ def children(self) -> Iterator[Tuple[str, "Device"]]:
53
+ for attr_name, attr in self.__dict__.items():
54
+ if attr_name != "parent" and isinstance(attr, Device):
55
+ yield attr_name, attr
56
+
57
+ def set_name(self, name: str):
58
+ """Set ``self.name=name`` and each ``self.child.name=name+"-child"``.
59
+
60
+ Parameters
61
+ ----------
62
+ name:
63
+ New name to set
64
+ """
65
+
66
+ # Ensure self.log is recreated after a name change
67
+ if hasattr(self, "log"):
68
+ del self.log
69
+
70
+ self._name = name
71
+ for attr_name, child in self.children():
72
+ child_name = f"{name}-{attr_name.rstrip('_')}" if name else ""
73
+ child.set_name(child_name)
74
+ child.parent = self
75
+
76
+ async def connect(
77
+ self,
78
+ mock: bool = False,
79
+ timeout: float = DEFAULT_TIMEOUT,
80
+ force_reconnect: bool = False,
81
+ ):
82
+ """Connect self and all child Devices.
83
+
84
+ Contains a timeout that gets propagated to child.connect methods.
85
+
86
+ Parameters
87
+ ----------
88
+ mock:
89
+ If True then use ``MockSignalBackend`` for all Signals
90
+ timeout:
91
+ Time to wait before failing with a TimeoutError.
92
+ """
93
+ # If previous connect with same args has started and not errored, can use it
94
+ can_use_previous_connect = (
95
+ self._connect_task
96
+ and not (self._connect_task.done() and self._connect_task.exception())
97
+ and self._connect_mock_arg == mock
98
+ )
99
+ if force_reconnect or not can_use_previous_connect:
100
+ # Kick off a connection
101
+ coros = {
102
+ name: child_device.connect(
103
+ mock, timeout=timeout, force_reconnect=force_reconnect
104
+ )
105
+ for name, child_device in self.children()
106
+ }
107
+ self._connect_task = asyncio.create_task(wait_for_connection(**coros))
108
+ self._connect_mock_arg = mock
109
+
110
+ assert self._connect_task, "Connect task not created, this shouldn't happen"
111
+ # Wait for it to complete
112
+ await self._connect_task
113
+
114
+
115
+ VT = TypeVar("VT", bound=Device)
116
+
117
+
118
+ class DeviceVector(Dict[int, VT], Device):
119
+ """
120
+ Defines device components with indices.
121
+
122
+ In the below example, foos becomes a dictionary on the parent device
123
+ at runtime, so parent.foos[2] returns a FooDevice. For example usage see
124
+ :class:`~ophyd_async.epics.demo.DynamicSensorGroup`
125
+ """
126
+
127
+ def children(self) -> Generator[Tuple[str, Device], None, None]:
128
+ for attr_name, attr in self.items():
129
+ if isinstance(attr, Device):
130
+ yield str(attr_name), attr
131
+
132
+
133
+ class DeviceCollector:
134
+ """Collector of top level Device instances to be used as a context manager
135
+
136
+ Parameters
137
+ ----------
138
+ set_name:
139
+ If True, call ``device.set_name(variable_name)`` on all collected
140
+ Devices
141
+ connect:
142
+ If True, call ``device.connect(mock)`` in parallel on all
143
+ collected Devices
144
+ mock:
145
+ If True, connect Signals in simulation mode
146
+ timeout:
147
+ How long to wait for connect before logging an exception
148
+
149
+ Notes
150
+ -----
151
+ Example usage::
152
+
153
+ [async] with DeviceCollector():
154
+ t1x = motor.Motor("BLxxI-MO-TABLE-01:X")
155
+ t1y = motor.Motor("pva://BLxxI-MO-TABLE-01:Y")
156
+ # Names and connects devices here
157
+ assert t1x.comm.velocity.source
158
+ assert t1x.name == "t1x"
159
+
160
+ """
161
+
162
+ def __init__(
163
+ self,
164
+ set_name=True,
165
+ connect=True,
166
+ mock=False,
167
+ timeout: float = 10.0,
168
+ ):
169
+ self._set_name = set_name
170
+ self._connect = connect
171
+ self._mock = mock
172
+ self._timeout = timeout
173
+ self._names_on_enter: Set[str] = set()
174
+ self._objects_on_exit: Dict[str, Any] = {}
175
+
176
+ def _caller_locals(self):
177
+ """Walk up until we find a stack frame that doesn't have us as self"""
178
+ try:
179
+ raise ValueError
180
+ except ValueError:
181
+ _, _, tb = sys.exc_info()
182
+ assert tb, "Can't get traceback, this shouldn't happen"
183
+ caller_frame = tb.tb_frame
184
+ while caller_frame.f_locals.get("self", None) is self:
185
+ caller_frame = caller_frame.f_back
186
+ return caller_frame.f_locals
187
+
188
+ def __enter__(self) -> "DeviceCollector":
189
+ # Stash the names that were defined before we were called
190
+ self._names_on_enter = set(self._caller_locals())
191
+ return self
192
+
193
+ async def __aenter__(self) -> "DeviceCollector":
194
+ return self.__enter__()
195
+
196
+ async def _on_exit(self) -> None:
197
+ # Name and kick off connect for devices
198
+ connect_coroutines: Dict[str, Coroutine] = {}
199
+ for name, obj in self._objects_on_exit.items():
200
+ if name not in self._names_on_enter and isinstance(obj, Device):
201
+ if self._set_name and not obj.name:
202
+ obj.set_name(name)
203
+ if self._connect:
204
+ connect_coroutines[name] = obj.connect(
205
+ self._mock, timeout=self._timeout
206
+ )
207
+
208
+ # Connect to all the devices
209
+ if connect_coroutines:
210
+ await wait_for_connection(**connect_coroutines)
211
+
212
+ async def __aexit__(self, type, value, traceback):
213
+ self._objects_on_exit = self._caller_locals()
214
+ await self._on_exit()
215
+
216
+ def __exit__(self, type_, value, traceback):
217
+ self._objects_on_exit = self._caller_locals()
218
+ try:
219
+ fut = call_in_bluesky_event_loop(self._on_exit())
220
+ except RuntimeError:
221
+ raise NotConnected(
222
+ "Could not connect devices. Is the bluesky event loop running? See "
223
+ "https://blueskyproject.io/ophyd-async/main/"
224
+ "user/explanations/event-loop-choice.html for more info."
225
+ )
226
+ return fut