ophyd-async 0.9.0a1__py3-none-any.whl → 0.10.0a1__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 (157) hide show
  1. ophyd_async/__init__.py +5 -8
  2. ophyd_async/_docs_parser.py +12 -0
  3. ophyd_async/_version.py +9 -4
  4. ophyd_async/core/__init__.py +102 -74
  5. ophyd_async/core/_derived_signal.py +271 -0
  6. ophyd_async/core/_derived_signal_backend.py +300 -0
  7. ophyd_async/core/_detector.py +158 -153
  8. ophyd_async/core/_device.py +143 -115
  9. ophyd_async/core/_device_filler.py +82 -9
  10. ophyd_async/core/_flyer.py +16 -7
  11. ophyd_async/core/_hdf_dataset.py +29 -22
  12. ophyd_async/core/_log.py +14 -23
  13. ophyd_async/core/_mock_signal_backend.py +11 -3
  14. ophyd_async/core/_protocol.py +65 -45
  15. ophyd_async/core/_providers.py +28 -9
  16. ophyd_async/core/_readable.py +74 -58
  17. ophyd_async/core/_settings.py +113 -0
  18. ophyd_async/core/_signal.py +304 -174
  19. ophyd_async/core/_signal_backend.py +60 -14
  20. ophyd_async/core/_soft_signal_backend.py +18 -12
  21. ophyd_async/core/_status.py +72 -24
  22. ophyd_async/core/_table.py +54 -17
  23. ophyd_async/core/_utils.py +101 -52
  24. ophyd_async/core/_yaml_settings.py +66 -0
  25. ophyd_async/epics/__init__.py +1 -0
  26. ophyd_async/epics/adandor/__init__.py +9 -0
  27. ophyd_async/epics/adandor/_andor.py +45 -0
  28. ophyd_async/epics/adandor/_andor_controller.py +51 -0
  29. ophyd_async/epics/adandor/_andor_io.py +34 -0
  30. ophyd_async/epics/adaravis/__init__.py +8 -1
  31. ophyd_async/epics/adaravis/_aravis.py +23 -41
  32. ophyd_async/epics/adaravis/_aravis_controller.py +23 -55
  33. ophyd_async/epics/adaravis/_aravis_io.py +13 -28
  34. ophyd_async/epics/adcore/__init__.py +36 -14
  35. ophyd_async/epics/adcore/_core_detector.py +81 -0
  36. ophyd_async/epics/adcore/_core_io.py +145 -95
  37. ophyd_async/epics/adcore/_core_logic.py +179 -88
  38. ophyd_async/epics/adcore/_core_writer.py +223 -0
  39. ophyd_async/epics/adcore/_hdf_writer.py +51 -92
  40. ophyd_async/epics/adcore/_jpeg_writer.py +26 -0
  41. ophyd_async/epics/adcore/_single_trigger.py +6 -5
  42. ophyd_async/epics/adcore/_tiff_writer.py +26 -0
  43. ophyd_async/epics/adcore/_utils.py +3 -2
  44. ophyd_async/epics/adkinetix/__init__.py +2 -1
  45. ophyd_async/epics/adkinetix/_kinetix.py +32 -27
  46. ophyd_async/epics/adkinetix/_kinetix_controller.py +11 -21
  47. ophyd_async/epics/adkinetix/_kinetix_io.py +12 -13
  48. ophyd_async/epics/adpilatus/__init__.py +7 -2
  49. ophyd_async/epics/adpilatus/_pilatus.py +28 -40
  50. ophyd_async/epics/adpilatus/_pilatus_controller.py +25 -22
  51. ophyd_async/epics/adpilatus/_pilatus_io.py +11 -9
  52. ophyd_async/epics/adsimdetector/__init__.py +8 -1
  53. ophyd_async/epics/adsimdetector/_sim.py +22 -16
  54. ophyd_async/epics/adsimdetector/_sim_controller.py +9 -43
  55. ophyd_async/epics/adsimdetector/_sim_io.py +10 -0
  56. ophyd_async/epics/advimba/__init__.py +10 -1
  57. ophyd_async/epics/advimba/_vimba.py +26 -25
  58. ophyd_async/epics/advimba/_vimba_controller.py +12 -24
  59. ophyd_async/epics/advimba/_vimba_io.py +23 -28
  60. ophyd_async/epics/core/_aioca.py +66 -30
  61. ophyd_async/epics/core/_epics_connector.py +4 -0
  62. ophyd_async/epics/core/_epics_device.py +2 -0
  63. ophyd_async/epics/core/_p4p.py +50 -18
  64. ophyd_async/epics/core/_pvi_connector.py +65 -8
  65. ophyd_async/epics/core/_signal.py +51 -51
  66. ophyd_async/epics/core/_util.py +5 -5
  67. ophyd_async/epics/demo/__init__.py +11 -49
  68. ophyd_async/epics/demo/__main__.py +31 -0
  69. ophyd_async/epics/demo/_ioc.py +32 -0
  70. ophyd_async/epics/demo/_motor.py +82 -0
  71. ophyd_async/epics/demo/_point_detector.py +42 -0
  72. ophyd_async/epics/demo/_point_detector_channel.py +22 -0
  73. ophyd_async/epics/demo/_stage.py +15 -0
  74. ophyd_async/epics/demo/{mover.db → motor.db} +2 -1
  75. ophyd_async/epics/demo/point_detector.db +59 -0
  76. ophyd_async/epics/demo/point_detector_channel.db +21 -0
  77. ophyd_async/epics/eiger/_eiger.py +1 -3
  78. ophyd_async/epics/eiger/_eiger_controller.py +11 -4
  79. ophyd_async/epics/eiger/_eiger_io.py +2 -0
  80. ophyd_async/epics/eiger/_odin_io.py +1 -2
  81. ophyd_async/epics/motor.py +83 -38
  82. ophyd_async/epics/signal.py +4 -1
  83. ophyd_async/epics/testing/__init__.py +14 -14
  84. ophyd_async/epics/testing/_example_ioc.py +68 -73
  85. ophyd_async/epics/testing/_utils.py +19 -44
  86. ophyd_async/epics/testing/test_records.db +16 -0
  87. ophyd_async/epics/testing/test_records_pva.db +17 -16
  88. ophyd_async/fastcs/__init__.py +1 -0
  89. ophyd_async/fastcs/core.py +6 -0
  90. ophyd_async/fastcs/odin/__init__.py +1 -0
  91. ophyd_async/fastcs/panda/__init__.py +8 -8
  92. ophyd_async/fastcs/panda/_block.py +29 -9
  93. ophyd_async/fastcs/panda/_control.py +12 -2
  94. ophyd_async/fastcs/panda/_hdf_panda.py +5 -1
  95. ophyd_async/fastcs/panda/_table.py +13 -7
  96. ophyd_async/fastcs/panda/_trigger.py +23 -9
  97. ophyd_async/fastcs/panda/_writer.py +27 -30
  98. ophyd_async/plan_stubs/__init__.py +16 -0
  99. ophyd_async/plan_stubs/_ensure_connected.py +12 -17
  100. ophyd_async/plan_stubs/_fly.py +3 -5
  101. ophyd_async/plan_stubs/_nd_attributes.py +9 -5
  102. ophyd_async/plan_stubs/_panda.py +14 -0
  103. ophyd_async/plan_stubs/_settings.py +152 -0
  104. ophyd_async/plan_stubs/_utils.py +3 -0
  105. ophyd_async/plan_stubs/_wait_for_awaitable.py +13 -0
  106. ophyd_async/sim/__init__.py +29 -0
  107. ophyd_async/sim/__main__.py +43 -0
  108. ophyd_async/sim/_blob_detector.py +33 -0
  109. ophyd_async/sim/_blob_detector_controller.py +48 -0
  110. ophyd_async/sim/_blob_detector_writer.py +105 -0
  111. ophyd_async/sim/_mirror_horizontal.py +46 -0
  112. ophyd_async/sim/_mirror_vertical.py +74 -0
  113. ophyd_async/sim/_motor.py +233 -0
  114. ophyd_async/sim/_pattern_generator.py +124 -0
  115. ophyd_async/sim/_point_detector.py +86 -0
  116. ophyd_async/sim/_stage.py +19 -0
  117. ophyd_async/tango/__init__.py +1 -0
  118. ophyd_async/tango/core/__init__.py +6 -1
  119. ophyd_async/tango/core/_base_device.py +41 -33
  120. ophyd_async/tango/core/_converters.py +81 -0
  121. ophyd_async/tango/core/_signal.py +21 -33
  122. ophyd_async/tango/core/_tango_readable.py +2 -19
  123. ophyd_async/tango/core/_tango_transport.py +148 -74
  124. ophyd_async/tango/core/_utils.py +47 -0
  125. ophyd_async/tango/demo/_counter.py +2 -0
  126. ophyd_async/tango/demo/_detector.py +2 -0
  127. ophyd_async/tango/demo/_mover.py +10 -6
  128. ophyd_async/tango/demo/_tango/_servers.py +4 -0
  129. ophyd_async/tango/testing/__init__.py +6 -0
  130. ophyd_async/tango/testing/_one_of_everything.py +200 -0
  131. ophyd_async/testing/__init__.py +48 -7
  132. ophyd_async/testing/__pytest_assert_rewrite.py +4 -0
  133. ophyd_async/testing/_assert.py +200 -96
  134. ophyd_async/testing/_mock_signal_utils.py +59 -73
  135. ophyd_async/testing/_one_of_everything.py +146 -0
  136. ophyd_async/testing/_single_derived.py +87 -0
  137. ophyd_async/testing/_utils.py +3 -0
  138. {ophyd_async-0.9.0a1.dist-info → ophyd_async-0.10.0a1.dist-info}/METADATA +25 -26
  139. ophyd_async-0.10.0a1.dist-info/RECORD +149 -0
  140. {ophyd_async-0.9.0a1.dist-info → ophyd_async-0.10.0a1.dist-info}/WHEEL +1 -1
  141. ophyd_async/core/_device_save_loader.py +0 -274
  142. ophyd_async/epics/demo/_mover.py +0 -95
  143. ophyd_async/epics/demo/_sensor.py +0 -37
  144. ophyd_async/epics/demo/sensor.db +0 -19
  145. ophyd_async/fastcs/panda/_utils.py +0 -16
  146. ophyd_async/sim/demo/__init__.py +0 -19
  147. ophyd_async/sim/demo/_pattern_detector/__init__.py +0 -13
  148. ophyd_async/sim/demo/_pattern_detector/_pattern_detector.py +0 -42
  149. ophyd_async/sim/demo/_pattern_detector/_pattern_detector_controller.py +0 -62
  150. ophyd_async/sim/demo/_pattern_detector/_pattern_detector_writer.py +0 -41
  151. ophyd_async/sim/demo/_pattern_detector/_pattern_generator.py +0 -207
  152. ophyd_async/sim/demo/_sim_motor.py +0 -107
  153. ophyd_async/sim/testing/__init__.py +0 -0
  154. ophyd_async-0.9.0a1.dist-info/RECORD +0 -119
  155. ophyd_async-0.9.0a1.dist-info/entry_points.txt +0 -2
  156. {ophyd_async-0.9.0a1.dist-info → ophyd_async-0.10.0a1.dist-info/licenses}/LICENSE +0 -0
  157. {ophyd_async-0.9.0a1.dist-info → ophyd_async-0.10.0a1.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,8 @@
1
1
  import logging
2
2
  import sys
3
+ import typing
3
4
  from collections.abc import Sequence
5
+ from functools import cache
4
6
  from math import isnan, nan
5
7
  from typing import Any, Generic, cast
6
8
 
@@ -35,16 +37,19 @@ from ophyd_async.core import (
35
37
 
36
38
  from ._util import EpicsSignalBackend, format_datatype, get_supported_values
37
39
 
40
+ logger = logging.getLogger("ophyd_async")
41
+
38
42
 
39
43
  def _limits_from_augmented_value(value: AugmentedValue) -> Limits:
40
44
  def get_limits(limit: str) -> LimitsRange | None:
41
45
  low = getattr(value, f"lower_{limit}_limit", nan)
42
46
  high = getattr(value, f"upper_{limit}_limit", nan)
43
- if not (isnan(low) and isnan(high)):
47
+ if not (isnan(low) and isnan(high)) and not high == low == 0:
44
48
  return LimitsRange(
45
49
  low=None if isnan(low) else low,
46
50
  high=None if isnan(high) else high,
47
51
  )
52
+ return None
48
53
 
49
54
  limits = Limits()
50
55
  if limits_range := get_limits("alarm"):
@@ -59,14 +64,20 @@ def _limits_from_augmented_value(value: AugmentedValue) -> Limits:
59
64
 
60
65
 
61
66
  def _metadata_from_augmented_value(
62
- value: AugmentedValue, metadata: SignalMetadata
67
+ datatype: type[SignalDatatypeT] | None,
68
+ value: AugmentedValue,
69
+ metadata: SignalMetadata,
63
70
  ) -> SignalMetadata:
64
71
  metadata = metadata.copy()
65
- if hasattr(value, "units"):
72
+ if hasattr(value, "units") and datatype not in (str, bool):
66
73
  metadata["units"] = value.units
67
- if hasattr(value, "precision") and not isnan(value.precision):
74
+ if (
75
+ hasattr(value, "precision")
76
+ and not isnan(value.precision)
77
+ and datatype is not int
78
+ ):
68
79
  metadata["precision"] = value.precision
69
- if limits := _limits_from_augmented_value(value):
80
+ if (limits := _limits_from_augmented_value(value)) and datatype is not bool:
70
81
  metadata["limits"] = limits
71
82
  return metadata
72
83
 
@@ -100,6 +111,11 @@ class DisconnectedCaConverter(CaConverter):
100
111
  raise NotImplementedError("No PV has been set as connect() has not been called")
101
112
 
102
113
 
114
+ class CaIntConverter(CaConverter[int]):
115
+ def value(self, value: AugmentedValue) -> int:
116
+ return int(value) # type: ignore
117
+
118
+
103
119
  class CaArrayConverter(CaConverter[np.ndarray]):
104
120
  def value(self, value: AugmentedValue) -> np.ndarray:
105
121
  # A less expensive conversion
@@ -168,6 +184,9 @@ def make_converter(
168
184
  Dbr, get_unique({k: v.datatype for k, v in values.items()}, "datatypes")
169
185
  )
170
186
  is_array = bool([v for v in values.values() if v.element_count > 1])
187
+ # Make the datatype canonical for the inference below
188
+ if datatype == typing.Sequence[str]:
189
+ datatype = Sequence[str]
171
190
  # Infer a datatype and converter from the dbr
172
191
  inferred_datatype, converter_cls = _datatype_converter_from_dbr[(pv_dbr, is_array)]
173
192
  # Some override cases
@@ -202,7 +221,7 @@ def make_converter(
202
221
  and get_unique({k: v.precision for k, v in values.items()}, "precision") == 0
203
222
  ):
204
223
  # Allow int signals to represent float records when prec is 0
205
- return CaConverter(int, pv_dbr)
224
+ return CaIntConverter(int, pv_dbr)
206
225
  elif datatype in (None, inferred_datatype):
207
226
  # If datatype matches what we are given then allow it and use inferred converter
208
227
  return converter_cls(inferred_datatype, pv_dbr)
@@ -214,19 +233,18 @@ def make_converter(
214
233
  )
215
234
 
216
235
 
217
- _tried_pyepics = False
218
-
219
-
236
+ # Cached call to avoid repeated initialization attempts
237
+ @cache
220
238
  def _use_pyepics_context_if_imported():
221
- global _tried_pyepics
222
- if not _tried_pyepics:
223
- ca = sys.modules.get("epics.ca", None)
224
- if ca:
225
- ca.use_initial_context()
226
- _tried_pyepics = True
239
+ """Sets up the pyepics context if the module is imported."""
240
+ ca = sys.modules.get("epics.ca", None)
241
+ if ca:
242
+ ca.use_initial_context()
227
243
 
228
244
 
229
245
  class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
246
+ """Backend for a signal to interact with PVs over channel access."""
247
+
230
248
  def __init__(
231
249
  self,
232
250
  datatype: type[SignalDatatypeT] | None,
@@ -247,7 +265,7 @@ class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
247
265
  pv, format=FORMAT_CTRL, timeout=timeout
248
266
  )
249
267
  except CANothing as exc:
250
- logging.debug(f"signal ca://{pv} timed out")
268
+ logger.debug(f"signal ca://{pv} timed out")
251
269
  raise NotConnected(f"ca://{pv}") from exc
252
270
 
253
271
  async def connect(self, timeout: float):
@@ -280,17 +298,33 @@ class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
280
298
  write_value = self.initial_values[self.write_pv]
281
299
  else:
282
300
  write_value = self.converter.write_value(value)
283
- await caput(
284
- self.write_pv,
285
- write_value,
286
- datatype=self.converter.write_dbr,
287
- wait=wait,
288
- timeout=None,
289
- )
301
+ try:
302
+ await caput(
303
+ self.write_pv,
304
+ write_value,
305
+ datatype=self.converter.write_dbr,
306
+ wait=wait,
307
+ timeout=None,
308
+ )
309
+ except CANothing as exc:
310
+ # If we ran into a write error, check to see if there is a list
311
+ # of valid choices, and if the value we tried to write is in that list.
312
+ valid_choices = self.converter.metadata.get("choices")
313
+ if valid_choices:
314
+ if value not in valid_choices:
315
+ msg = (
316
+ f"{value} is not a valid choice for {self.write_pv}, "
317
+ f"valid choices: {self.converter.metadata.get('choices')}"
318
+ )
319
+ raise ValueError(msg) from exc
320
+ raise
321
+ raise
290
322
 
291
323
  async def get_datakey(self, source: str) -> DataKey:
292
324
  value = await self._caget(self.read_pv, FORMAT_CTRL)
293
- metadata = _metadata_from_augmented_value(value, self.converter.metadata)
325
+ metadata = _metadata_from_augmented_value(
326
+ self.datatype, value, self.converter.metadata
327
+ )
294
328
  return make_datakey(
295
329
  self.converter.datatype, self.converter.value(value), source, metadata
296
330
  )
@@ -308,16 +342,18 @@ class CaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
308
342
  return self.converter.value(value)
309
343
 
310
344
  def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None:
345
+ if callback and self.subscription:
346
+ msg = "Cannot set a callback when one is already set"
347
+ raise RuntimeError(msg)
348
+
349
+ if self.subscription:
350
+ self.subscription.close()
351
+ self.subscription = None
352
+
311
353
  if callback:
312
- assert (
313
- not self.subscription
314
- ), "Cannot set a callback when one is already set"
315
354
  self.subscription = camonitor(
316
355
  self.read_pv,
317
356
  lambda v: callback(self._make_reading(v)),
318
357
  datatype=self.converter.read_dbr,
319
358
  format=FORMAT_TIME,
320
359
  )
321
- elif self.subscription:
322
- self.subscription.close()
323
- self.subscription = None
@@ -10,6 +10,8 @@ from ._signal import EpicsSignalBackend, get_signal_backend_type, split_protocol
10
10
 
11
11
  @dataclass
12
12
  class PvSuffix:
13
+ """Define the PV suffix to be appended to the device prefix."""
14
+
13
15
  read_suffix: str
14
16
  write_suffix: str | None = None
15
17
 
@@ -36,6 +38,8 @@ def fill_backend_with_prefix(
36
38
 
37
39
 
38
40
  class EpicsDeviceConnector(DeviceConnector):
41
+ """Used for connecting signals to static EPICS pvs."""
42
+
39
43
  def __init__(self, prefix: str) -> None:
40
44
  self.prefix = prefix
41
45
 
@@ -5,6 +5,8 @@ from ._pvi_connector import PviDeviceConnector
5
5
 
6
6
 
7
7
  class EpicsDevice(Device):
8
+ """Baseclass to allow child signals to be created declaratively."""
9
+
8
10
  def __init__(self, prefix: str, with_pvi: bool = False, name: str = ""):
9
11
  if with_pvi:
10
12
  connector = PviDeviceConnector(prefix)
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import atexit
5
5
  import logging
6
+ import typing
6
7
  from collections.abc import Mapping, Sequence
7
8
  from math import isnan, nan
8
9
  from typing import Any, Generic
@@ -31,6 +32,8 @@ from ophyd_async.core import (
31
32
 
32
33
  from ._util import EpicsSignalBackend, format_datatype, get_supported_values
33
34
 
35
+ logger = logging.getLogger("ophyd_async")
36
+
34
37
 
35
38
  def _limits_from_value(value: Any) -> Limits:
36
39
  def get_limits(
@@ -39,11 +42,12 @@ def _limits_from_value(value: Any) -> Limits:
39
42
  substructure = getattr(value, substucture_name, None)
40
43
  low = getattr(substructure, low_name, nan)
41
44
  high = getattr(substructure, high_name, nan)
42
- if not (isnan(low) and isnan(high)):
45
+ if not (isnan(low) and isnan(high)) and not low == high == 0:
43
46
  return LimitsRange(
44
47
  low=None if isnan(low) else low,
45
48
  high=None if isnan(high) else high,
46
49
  )
50
+ return None
47
51
 
48
52
  limits = Limits()
49
53
  if limits_range := get_limits("valueAlarm", "lowAlarmLimit", "highAlarmLimit"):
@@ -60,12 +64,22 @@ def _limits_from_value(value: Any) -> Limits:
60
64
  def _metadata_from_value(datatype: type[SignalDatatype], value: Any) -> SignalMetadata:
61
65
  metadata = SignalMetadata()
62
66
  value_data: Any = getattr(value, "value", None)
67
+ specifier = _get_specifier(value)
63
68
  display_data: Any = getattr(value, "display", None)
64
- if hasattr(display_data, "units"):
69
+ if (
70
+ hasattr(display_data, "units")
71
+ and specifier[-1] in _number_specifiers
72
+ and datatype is not str
73
+ ):
65
74
  metadata["units"] = display_data.units
66
- if hasattr(display_data, "precision") and not isnan(display_data.precision):
75
+ if (
76
+ hasattr(display_data, "precision")
77
+ and not isnan(display_data.precision)
78
+ and specifier[-1] in _float_specifiers
79
+ and datatype is not int
80
+ ):
67
81
  metadata["precision"] = display_data.precision
68
- if limits := _limits_from_value(value):
82
+ if (limits := _limits_from_value(value)) and specifier[-1] in _number_specifiers:
69
83
  metadata["limits"] = limits
70
84
  # Get choices from display or value
71
85
  if datatype is str or issubclass(datatype, StrictEnum):
@@ -84,9 +98,7 @@ class PvaConverter(Generic[SignalDatatypeT]):
84
98
  self.datatype = datatype
85
99
 
86
100
  def value(self, value: Any) -> SignalDatatypeT:
87
- # for channel access ca_xxx classes, this
88
- # invokes __pos__ operator to return an instance of
89
- # the builtin base class
101
+ # Normally the value will be of the correct python type
90
102
  return value["value"]
91
103
 
92
104
  def write_value(self, value: Any) -> Any:
@@ -94,6 +106,15 @@ class PvaConverter(Generic[SignalDatatypeT]):
94
106
  return value
95
107
 
96
108
 
109
+ class PvaIntConverter(PvaConverter[int]):
110
+ def __init__(self):
111
+ super().__init__(int)
112
+
113
+ def value(self, value: Any) -> int:
114
+ # Convert to an int
115
+ return int(value["value"])
116
+
117
+
97
118
  class PvaLongStringConverter(PvaConverter[str]):
98
119
  def __init__(self):
99
120
  super().__init__(str)
@@ -174,6 +195,9 @@ class PvaTableConverter(PvaConverter[Table]):
174
195
 
175
196
 
176
197
  # https://mdavidsaver.github.io/p4p/values.html
198
+ _float_specifiers = {"f", "d"}
199
+ _int_specifiers = {"b", "B", "h", "H", "i", "I", "l", "L"}
200
+ _number_specifiers = _float_specifiers.union(_int_specifiers)
177
201
  _datatype_converter_from_typeid: dict[
178
202
  tuple[str, str], tuple[type[SignalDatatype], type[PvaConverter]]
179
203
  ] = {
@@ -208,7 +232,7 @@ _datatype_converter_from_typeid: dict[
208
232
  }
209
233
 
210
234
 
211
- def _get_specifier(value: Value):
235
+ def _get_specifier(value: Value) -> str:
212
236
  typ = value.type("value").aspy()
213
237
  if isinstance(typ, tuple):
214
238
  return typ[0]
@@ -223,6 +247,9 @@ def make_converter(datatype: type | None, values: dict[str, Any]) -> PvaConverte
223
247
  {k: _get_specifier(v) for k, v in values.items()},
224
248
  "value type specifiers",
225
249
  )
250
+ # Make the datatype canonical for the inference below
251
+ if datatype == typing.Sequence[str]:
252
+ datatype = Sequence[str]
226
253
  # Infer a datatype and converter from the typeid and specifier
227
254
  inferred_datatype, converter_cls = _datatype_converter_from_typeid[
228
255
  (typeid, specifier)
@@ -258,7 +285,7 @@ def make_converter(datatype: type | None, values: dict[str, Any]) -> PvaConverte
258
285
  == 0
259
286
  ):
260
287
  # Allow int signals to represent float records when prec is 0
261
- return PvaConverter(int)
288
+ return PvaIntConverter()
262
289
  elif inferred_datatype is str and (enum_cls := get_enum_cls(datatype)):
263
290
  # Allow strings to be used as enums until QSRV supports this
264
291
  return PvaConverter(str)
@@ -301,18 +328,21 @@ async def pvget_with_timeout(pv: str, timeout: float) -> Any:
301
328
  try:
302
329
  return await asyncio.wait_for(context().get(pv), timeout=timeout)
303
330
  except asyncio.TimeoutError as exc:
304
- logging.debug(f"signal pva://{pv} timed out", exc_info=True)
331
+ logger.debug(f"signal pva://{pv} timed out", exc_info=True)
305
332
  raise NotConnected(f"pva://{pv}") from exc
306
333
 
307
334
 
308
335
  def _pva_request_string(fields: Sequence[str]) -> str:
309
- """Converts a list of requested fields into a PVA request string which can be
310
- passed to p4p.
336
+ """Convert a list of requested fields into a PVA request string.
337
+
338
+ This can be passed to p4p.
311
339
  """
312
340
  return f"field({','.join(fields)})"
313
341
 
314
342
 
315
343
  class PvaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
344
+ """Backend for a signal to interact with PVs over pva."""
345
+
316
346
  def __init__(
317
347
  self,
318
348
  datatype: type[SignalDatatypeT] | None,
@@ -383,10 +413,15 @@ class PvaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
383
413
  return self.converter.value(value)
384
414
 
385
415
  def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None:
416
+ if callback and self.subscription:
417
+ msg = "Cannot set a callback when one is already set"
418
+ raise RuntimeError(msg)
419
+
420
+ if self.subscription:
421
+ self.subscription.close()
422
+ self.subscription = None
423
+
386
424
  if callback:
387
- assert (
388
- not self.subscription
389
- ), "Cannot set a callback when one is already set"
390
425
 
391
426
  async def async_callback(v):
392
427
  callback(self._make_reading(v))
@@ -397,6 +432,3 @@ class PvaSignalBackend(EpicsSignalBackend[SignalDatatypeT]):
397
432
  self.subscription = context().monitor(
398
433
  self.read_pv, async_callback, request=request
399
434
  )
400
- elif self.subscription:
401
- self.subscription.close()
402
- self.subscription = None
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import Literal, cast
4
+
3
5
  from ophyd_async.core import (
4
6
  Device,
5
7
  DeviceConnector,
@@ -7,6 +9,7 @@ from ophyd_async.core import (
7
9
  Signal,
8
10
  SignalR,
9
11
  SignalRW,
12
+ SignalW,
10
13
  SignalX,
11
14
  )
12
15
  from ophyd_async.core._utils import LazyMock
@@ -16,6 +19,27 @@ from ._signal import PvaSignalBackend, pvget_with_timeout
16
19
 
17
20
  Entry = dict[str, str]
18
21
 
22
+ OldPVIVector = list[Entry | None]
23
+ # The older PVI structure has vectors of the form
24
+ # structure[] ttlout
25
+ # (none)
26
+ # structure
27
+ # string d PANDABLOCKS_IOC:TTLOUT1:PVI
28
+ # structure
29
+ # string d PANDABLOCKS_IOC:TTLOUT2:PVI
30
+ # structure
31
+ # string d PANDABLOCKS_IOC:TTLOUT3:PVI
32
+
33
+
34
+ FastCSPVIVector = dict[Literal["d"], Entry]
35
+ # The newer pva FastCS PVI structure has vectors of the form
36
+ # structure ttlout
37
+ # structure d
38
+ # string v1 FASTCS_PANDA:Ttlout1:PVI
39
+ # string v2 FASTCS_PANDA:Ttlout2:PVI
40
+ # string v3 FASTCS_PANDA:Ttlout3:PVI
41
+ # string v4 FASTCS_PANDA:Ttlout4:PVI
42
+
19
43
 
20
44
  def _get_signal_details(entry: Entry) -> tuple[type[Signal], str, str]:
21
45
  match entry:
@@ -23,6 +47,8 @@ def _get_signal_details(entry: Entry) -> tuple[type[Signal], str, str]:
23
47
  return SignalR, read_pv, read_pv
24
48
  case {"r": read_pv, "w": write_pv}:
25
49
  return SignalRW, read_pv, write_pv
50
+ case {"w": write_pv}:
51
+ return SignalW, write_pv, write_pv
26
52
  case {"rw": read_write_pv}:
27
53
  return SignalRW, read_write_pv, read_write_pv
28
54
  case {"x": execute_pv}:
@@ -31,7 +57,26 @@ def _get_signal_details(entry: Entry) -> tuple[type[Signal], str, str]:
31
57
  raise TypeError(f"Can't process entry {entry}")
32
58
 
33
59
 
60
+ def _is_device_vector_entry(entry: Entry | OldPVIVector | FastCSPVIVector) -> bool:
61
+ return isinstance(entry, list) or (
62
+ entry.keys() == {"d"} and isinstance(entry["d"], dict)
63
+ )
64
+
65
+
34
66
  class PviDeviceConnector(DeviceConnector):
67
+ """Connect to PVI structure served over PVA.
68
+
69
+ At init, fill in all the type hinted signals. At connection check their
70
+ types and fill in any extra signals.
71
+
72
+ :param prefix:
73
+ The PV prefix of the device, "PVI" will be appended to it to get the PVI
74
+ PV.
75
+ :param error_hint:
76
+ If given, this will be appended to the error message if any of they type
77
+ hinted Signals are not present.
78
+ """
79
+
35
80
  def __init__(self, prefix: str = "", error_hint: str = "") -> None:
36
81
  # TODO: what happens if we get a leading "pva://" here?
37
82
  self.prefix = prefix
@@ -70,21 +115,33 @@ class PviDeviceConnector(DeviceConnector):
70
115
  device.set_name(device.name)
71
116
  return await super().connect_mock(device, mock)
72
117
 
118
+ def _fill_vector_child(self, name: str, entry: OldPVIVector | FastCSPVIVector):
119
+ if isinstance(entry, list):
120
+ for i, e in enumerate(entry):
121
+ if e:
122
+ self._fill_child(name, e, i)
123
+ else:
124
+ for i_string, e in entry["d"].items():
125
+ self._fill_child(name, {"d": e}, int(i_string.lstrip("v")))
126
+
73
127
  async def connect_real(
74
128
  self, device: Device, timeout: float, force_reconnect: bool
75
129
  ) -> None:
76
130
  pvi_structure = await pvget_with_timeout(self.pvi_pv, timeout)
77
- entries: dict[str, Entry | list[Entry | None]] = pvi_structure["value"].todict()
131
+
132
+ entries: dict[str, Entry | OldPVIVector | FastCSPVIVector] = pvi_structure[
133
+ "value"
134
+ ].todict()
78
135
  # Fill based on what PVI gives us
79
136
  for name, entry in entries.items():
80
- if isinstance(entry, dict):
81
- # This is a child
82
- self._fill_child(name, entry)
137
+ if _is_device_vector_entry(entry):
138
+ self._fill_vector_child(
139
+ name, cast(OldPVIVector | FastCSPVIVector, entry)
140
+ )
83
141
  else:
84
- # This is a DeviceVector of children
85
- for i, e in enumerate(entry):
86
- if e:
87
- self._fill_child(name, e, i)
142
+ # This is a child
143
+ self._fill_child(name, cast(Entry, entry))
144
+
88
145
  # Check that all the requested children have been filled
89
146
  suffix = f"\n{self.error_hint}" if self.error_hint else ""
90
147
  self.filler.check_filled(f"{self.pvi_pv}: {entries}{suffix}")
@@ -1,10 +1,11 @@
1
- """EPICS Signals over CA or PVA"""
1
+ """EPICS Signals over CA or PVA."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  from enum import Enum
6
6
 
7
7
  from ophyd_async.core import (
8
+ DEFAULT_TIMEOUT,
8
9
  SignalBackend,
9
10
  SignalDatatypeT,
10
11
  SignalR,
@@ -73,6 +74,7 @@ def get_signal_backend_type(protocol: EpicsProtocol) -> type[EpicsSignalBackend]
73
74
  return CaSignalBackend
74
75
  case EpicsProtocol.PVA:
75
76
  return PvaSignalBackend
77
+ raise TypeError(f"Unsupported protocol: {protocol}")
76
78
 
77
79
 
78
80
  def _epics_signal_backend(
@@ -91,20 +93,18 @@ def epics_signal_rw(
91
93
  read_pv: str,
92
94
  write_pv: str | None = None,
93
95
  name: str = "",
96
+ timeout: float = DEFAULT_TIMEOUT,
94
97
  ) -> SignalRW[SignalDatatypeT]:
95
- """Create a `SignalRW` backed by 1 or 2 EPICS PVs
96
-
97
- Parameters
98
- ----------
99
- datatype:
100
- Check that the PV is of this type
101
- read_pv:
102
- The PV to read and monitor
103
- write_pv:
104
- If given, use this PV to write to, otherwise use read_pv
98
+ """Create a `SignalRW` backed by 1 or 2 EPICS PVs.
99
+
100
+ :param datatype: Check that the PV is of this type
101
+ :param read_pv: The PV to read and monitor
102
+ :param write_pv: If given, use this PV to write to, otherwise use read_pv
103
+ :param name: The name of the signal (defaults to empty string)
104
+ :param timeout: A timeout to be used when reading (not connecting) this signal
105
105
  """
106
106
  backend = _epics_signal_backend(datatype, read_pv, write_pv or read_pv)
107
- return SignalRW(backend, name=name)
107
+ return SignalRW(backend, name=name, timeout=timeout)
108
108
 
109
109
 
110
110
  def epics_signal_rw_rbv(
@@ -112,67 +112,67 @@ def epics_signal_rw_rbv(
112
112
  write_pv: str,
113
113
  read_suffix: str = "_RBV",
114
114
  name: str = "",
115
+ timeout: float = DEFAULT_TIMEOUT,
115
116
  ) -> SignalRW[SignalDatatypeT]:
116
- """Create a `SignalRW` backed by 1 or 2 EPICS PVs, with a suffix on the readback pv
117
-
118
- Parameters
119
- ----------
120
- datatype:
121
- Check that the PV is of this type
122
- write_pv:
123
- The PV to write to
124
- read_suffix:
125
- Append this suffix to the write pv to create the readback pv
126
- """
117
+ """Create a `SignalRW` backed by 1 or 2 EPICS PVs, with a suffix on the readback pv.
127
118
 
119
+ :param datatype: Check that the PV is of this type
120
+ :param write_pv: The PV to write to
121
+ :param read_suffix: Append this suffix to the write pv to create the readback pv
122
+ :param name: The name of the signal (defaults to empty string)
123
+ :param timeout: A timeout to be used when reading (not connecting) this signal
124
+ """
128
125
  base_pv, field = get_pv_basename_and_field(write_pv)
129
126
  if field is not None:
130
127
  read_pv = f"{base_pv}{read_suffix}.{field}"
131
128
  else:
132
129
  read_pv = f"{write_pv}{read_suffix}"
133
130
 
134
- return epics_signal_rw(datatype, read_pv, write_pv, name)
131
+ return epics_signal_rw(datatype, read_pv, write_pv, name, timeout=timeout)
135
132
 
136
133
 
137
134
  def epics_signal_r(
138
- datatype: type[SignalDatatypeT], read_pv: str, name: str = ""
135
+ datatype: type[SignalDatatypeT],
136
+ read_pv: str,
137
+ name: str = "",
138
+ timeout: float = DEFAULT_TIMEOUT,
139
139
  ) -> SignalR[SignalDatatypeT]:
140
- """Create a `SignalR` backed by 1 EPICS PV
141
-
142
- Parameters
143
- ----------
144
- datatype
145
- Check that the PV is of this type
146
- read_pv:
147
- The PV to read and monitor
140
+ """Create a `SignalR` backed by 1 EPICS PV.
141
+
142
+ :param datatype: Check that the PV is of this type
143
+ :param read_pv: The PV to read from
144
+ :param name: The name of the signal (defaults to empty string)
145
+ :param timeout: A timeout to be used when reading (not connecting) this signal
148
146
  """
149
147
  backend = _epics_signal_backend(datatype, read_pv, read_pv)
150
- return SignalR(backend, name=name)
148
+ return SignalR(backend, name=name, timeout=timeout)
151
149
 
152
150
 
153
151
  def epics_signal_w(
154
- datatype: type[SignalDatatypeT], write_pv: str, name: str = ""
152
+ datatype: type[SignalDatatypeT],
153
+ write_pv: str,
154
+ name: str = "",
155
+ timeout: float = DEFAULT_TIMEOUT,
155
156
  ) -> SignalW[SignalDatatypeT]:
156
- """Create a `SignalW` backed by 1 EPICS PVs
157
-
158
- Parameters
159
- ----------
160
- datatype:
161
- Check that the PV is of this type
162
- write_pv:
163
- The PV to write to
157
+ """Create a `SignalW` backed by 1 EPICS PVs.
158
+
159
+ :param datatype: Check that the PV is of this type
160
+ :param write_pv: The PV to write to
161
+ :param name: The name of the signal (defaults to empty string)
162
+ :param timeout: A timeout to be used when reading (not connecting) this signal
164
163
  """
165
164
  backend = _epics_signal_backend(datatype, write_pv, write_pv)
166
- return SignalW(backend, name=name)
165
+ return SignalW(backend, name=name, timeout=timeout)
167
166
 
168
167
 
169
- def epics_signal_x(write_pv: str, name: str = "") -> SignalX:
170
- """Create a `SignalX` backed by 1 EPICS PVs
168
+ def epics_signal_x(
169
+ write_pv: str, name: str = "", timeout: float = DEFAULT_TIMEOUT
170
+ ) -> SignalX:
171
+ """Create a `SignalX` backed by 1 EPICS PVs.
171
172
 
172
- Parameters
173
- ----------
174
- write_pv:
175
- The PV to write its initial value to on trigger
173
+ :param write_pv: The PV to write its initial value to on trigger
174
+ :param name: The name of the signal
175
+ :param timeout: A timeout to be used when reading (not connecting) this signal
176
176
  """
177
177
  backend = _epics_signal_backend(None, write_pv, write_pv)
178
- return SignalX(backend, name=name)
178
+ return SignalX(backend, name=name, timeout=timeout)