ophyd-async 0.2.0__py3-none-any.whl → 0.3a2__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 (62) hide show
  1. ophyd_async/__init__.py +1 -4
  2. ophyd_async/_version.py +2 -2
  3. ophyd_async/core/__init__.py +16 -10
  4. ophyd_async/core/_providers.py +38 -5
  5. ophyd_async/core/async_status.py +3 -3
  6. ophyd_async/core/detector.py +211 -62
  7. ophyd_async/core/device.py +45 -38
  8. ophyd_async/core/device_save_loader.py +96 -23
  9. ophyd_async/core/flyer.py +30 -244
  10. ophyd_async/core/signal.py +47 -21
  11. ophyd_async/core/signal_backend.py +7 -4
  12. ophyd_async/core/sim_signal_backend.py +30 -18
  13. ophyd_async/core/standard_readable.py +4 -2
  14. ophyd_async/core/utils.py +93 -30
  15. ophyd_async/epics/_backend/_aioca.py +30 -36
  16. ophyd_async/epics/_backend/_p4p.py +75 -41
  17. ophyd_async/epics/_backend/common.py +25 -0
  18. ophyd_async/epics/areadetector/__init__.py +4 -0
  19. ophyd_async/epics/areadetector/aravis.py +69 -0
  20. ophyd_async/epics/areadetector/controllers/ad_sim_controller.py +1 -1
  21. ophyd_async/epics/areadetector/controllers/aravis_controller.py +73 -0
  22. ophyd_async/epics/areadetector/controllers/pilatus_controller.py +37 -25
  23. ophyd_async/epics/areadetector/drivers/aravis_driver.py +154 -0
  24. ophyd_async/epics/areadetector/drivers/pilatus_driver.py +4 -4
  25. ophyd_async/epics/areadetector/pilatus.py +50 -0
  26. ophyd_async/epics/areadetector/writers/_hdffile.py +21 -7
  27. ophyd_async/epics/areadetector/writers/hdf_writer.py +26 -15
  28. ophyd_async/epics/demo/__init__.py +33 -3
  29. ophyd_async/epics/motion/motor.py +20 -14
  30. ophyd_async/epics/pvi/__init__.py +3 -0
  31. ophyd_async/epics/pvi/pvi.py +318 -0
  32. ophyd_async/epics/signal/__init__.py +0 -2
  33. ophyd_async/epics/signal/signal.py +26 -9
  34. ophyd_async/panda/__init__.py +19 -5
  35. ophyd_async/panda/_common_blocks.py +49 -0
  36. ophyd_async/panda/_hdf_panda.py +48 -0
  37. ophyd_async/panda/_panda_controller.py +37 -0
  38. ophyd_async/panda/_trigger.py +39 -0
  39. ophyd_async/panda/_utils.py +15 -0
  40. ophyd_async/panda/writers/__init__.py +3 -0
  41. ophyd_async/panda/writers/_hdf_writer.py +220 -0
  42. ophyd_async/panda/writers/_panda_hdf_file.py +58 -0
  43. ophyd_async/planstubs/__init__.py +5 -0
  44. ophyd_async/planstubs/prepare_trigger_and_dets.py +57 -0
  45. ophyd_async/protocols.py +73 -0
  46. ophyd_async/sim/__init__.py +11 -0
  47. ophyd_async/sim/demo/__init__.py +3 -0
  48. ophyd_async/sim/demo/sim_motor.py +116 -0
  49. ophyd_async/sim/pattern_generator.py +318 -0
  50. ophyd_async/sim/sim_pattern_detector_control.py +55 -0
  51. ophyd_async/sim/sim_pattern_detector_writer.py +34 -0
  52. ophyd_async/sim/sim_pattern_generator.py +37 -0
  53. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3a2.dist-info}/METADATA +20 -76
  54. ophyd_async-0.3a2.dist-info/RECORD +76 -0
  55. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3a2.dist-info}/WHEEL +1 -1
  56. ophyd_async/epics/signal/pvi_get.py +0 -22
  57. ophyd_async/panda/panda.py +0 -294
  58. ophyd_async-0.2.0.dist-info/RECORD +0 -53
  59. /ophyd_async/panda/{table.py → _table.py} +0 -0
  60. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3a2.dist-info}/LICENSE +0 -0
  61. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3a2.dist-info}/entry_points.txt +0 -0
  62. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3a2.dist-info}/top_level.txt +0 -0
@@ -2,17 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import inspect
5
- import re
6
5
  import time
7
6
  from collections import abc
8
7
  from dataclasses import dataclass
9
8
  from enum import Enum
10
9
  from typing import Any, Dict, Generic, Optional, Type, Union, cast, get_origin
11
10
 
11
+ import numpy as np
12
12
  from bluesky.protocols import Descriptor, Dtype, Reading
13
13
 
14
14
  from .signal_backend import SignalBackend
15
- from .utils import ReadingValueCallback, T, get_dtype
15
+ from .utils import DEFAULT_TIMEOUT, ReadingValueCallback, T, get_dtype
16
16
 
17
17
  primitive_dtypes: Dict[type, Dtype] = {
18
18
  str: "string",
@@ -37,11 +37,16 @@ class SimConverter(Generic[T]):
37
37
  )
38
38
 
39
39
  def descriptor(self, source: str, value) -> Descriptor:
40
+ dtype = type(value)
41
+ if np.issubdtype(dtype, np.integer):
42
+ dtype = int
43
+ elif np.issubdtype(dtype, np.floating):
44
+ dtype = float
40
45
  assert (
41
- type(value) in primitive_dtypes
46
+ dtype in primitive_dtypes
42
47
  ), f"invalid converter for value of type {type(value)}"
43
- dtype = primitive_dtypes[type(value)]
44
- return dict(source=source, dtype=dtype, shape=[])
48
+ dtype_name = primitive_dtypes[dtype]
49
+ return {"source": source, "dtype": dtype_name, "shape": []}
45
50
 
46
51
  def make_initial_value(self, datatype: Optional[Type[T]]) -> T:
47
52
  if datatype is None:
@@ -52,7 +57,7 @@ class SimConverter(Generic[T]):
52
57
 
53
58
  class SimArrayConverter(SimConverter):
54
59
  def descriptor(self, source: str, value) -> Descriptor:
55
- return dict(source=source, dtype="array", shape=[len(value)])
60
+ return {"source": source, "dtype": "array", "shape": [len(value)]}
56
61
 
57
62
  def make_initial_value(self, datatype: Optional[Type[T]]) -> T:
58
63
  if datatype is None:
@@ -76,9 +81,7 @@ class SimEnumConverter(SimConverter):
76
81
 
77
82
  def descriptor(self, source: str, value) -> Descriptor:
78
83
  choices = [e.value for e in self.enum_class]
79
- return dict(
80
- source=source, dtype="string", shape=[], choices=choices
81
- ) # type: ignore
84
+ return {"source": source, "dtype": "string", "shape": [], "choices": choices} # type: ignore
82
85
 
83
86
  def make_initial_value(self, datatype: Optional[Type[T]]) -> T:
84
87
  if datatype is None:
@@ -109,23 +112,32 @@ class SimSignalBackend(SignalBackend[T]):
109
112
  """An simulated backend to a Signal, created with ``Signal.connect(sim=True)``"""
110
113
 
111
114
  _value: T
112
- _initial_value: T
115
+ _initial_value: Optional[T]
113
116
  _timestamp: float
114
117
  _severity: int
115
118
 
116
- def __init__(self, datatype: Optional[Type[T]], source: str) -> None:
117
- pv = re.split(r"://", source)[-1]
118
- self.source = f"sim://{pv}"
119
+ def __init__(
120
+ self,
121
+ datatype: Optional[Type[T]],
122
+ initial_value: Optional[T] = None,
123
+ ) -> None:
119
124
  self.datatype = datatype
120
- self.pv = source
121
125
  self.converter: SimConverter = DisconnectedSimConverter()
126
+ self._initial_value = initial_value
122
127
  self.put_proceeds = asyncio.Event()
123
128
  self.put_proceeds.set()
124
129
  self.callback: Optional[ReadingValueCallback[T]] = None
125
130
 
126
- async def connect(self) -> None:
131
+ def source(self, name: str) -> str:
132
+ return f"soft://{name}"
133
+
134
+ async def connect(self, timeout: float = DEFAULT_TIMEOUT) -> None:
127
135
  self.converter = make_converter(self.datatype)
128
- self._initial_value = self.converter.make_initial_value(self.datatype)
136
+ if self._initial_value is None:
137
+ self._initial_value = self.converter.make_initial_value(self.datatype)
138
+ else:
139
+ # convert potentially unconverted initial value passed to init method
140
+ self._initial_value = self.converter.write_value(self._initial_value)
129
141
  self._severity = 0
130
142
 
131
143
  await self.put(None)
@@ -152,8 +164,8 @@ class SimSignalBackend(SignalBackend[T]):
152
164
  if self.callback:
153
165
  self.callback(reading, self._value)
154
166
 
155
- async def get_descriptor(self) -> Descriptor:
156
- return self.converter.descriptor(self.source, self._value)
167
+ async def get_descriptor(self, source: str) -> Descriptor:
168
+ return self.converter.descriptor(source, self._value)
157
169
 
158
170
  async def get_reading(self) -> Reading:
159
171
  return self.converter.reading(self._value, self._timestamp, self._severity)
@@ -1,6 +1,8 @@
1
1
  from typing import Dict, Sequence, Tuple
2
2
 
3
- from bluesky.protocols import Configurable, Descriptor, Readable, Reading, Stageable
3
+ from bluesky.protocols import Descriptor, Reading, Stageable
4
+
5
+ from ophyd_async.protocols import AsyncConfigurable, AsyncReadable
4
6
 
5
7
  from .async_status import AsyncStatus
6
8
  from .device import Device
@@ -8,7 +10,7 @@ from .signal import SignalR
8
10
  from .utils import merge_gathered_dicts
9
11
 
10
12
 
11
- class StandardReadable(Device, Readable, Configurable, Stageable):
13
+ class StandardReadable(Device, AsyncReadable, AsyncConfigurable, Stageable):
12
14
  """Device that owns its children and provides useful default behavior.
13
15
 
14
16
  - When its name is set it renames child Devices
ophyd_async/core/utils.py CHANGED
@@ -1,5 +1,18 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
- from typing import Awaitable, Callable, Dict, Iterable, List, Optional, Type, TypeVar
4
+ import logging
5
+ from typing import (
6
+ Awaitable,
7
+ Callable,
8
+ Dict,
9
+ Iterable,
10
+ List,
11
+ Optional,
12
+ Type,
13
+ TypeVar,
14
+ Union,
15
+ )
3
16
 
4
17
  import numpy as np
5
18
  from bluesky.protocols import Reading
@@ -11,46 +24,79 @@ Callback = Callable[[T], None]
11
24
  #: monitor updates
12
25
  ReadingValueCallback = Callable[[Reading, T], None]
13
26
  DEFAULT_TIMEOUT = 10.0
27
+ ErrorText = Union[str, Dict[str, Exception]]
14
28
 
15
29
 
16
30
  class NotConnected(Exception):
17
31
  """Exception to be raised if a `Device.connect` is cancelled"""
18
32
 
19
- def __init__(self, *lines: str):
20
- self.lines = list(lines)
33
+ _indent_width = " "
34
+
35
+ def __init__(self, errors: ErrorText):
36
+ """
37
+ NotConnected holds a mapping of device/signal names to
38
+ errors.
39
+
40
+ Parameters
41
+ ----------
42
+ errors: ErrorText
43
+ Mapping of device name to Exception or another NotConnected.
44
+ Alternatively a string with the signal error text.
45
+ """
46
+
47
+ self._errors = errors
48
+
49
+ def _format_sub_errors(self, name: str, error: Exception, indent="") -> str:
50
+ if isinstance(error, NotConnected):
51
+ error_txt = ":" + error.format_error_string(indent + self._indent_width)
52
+ elif isinstance(error, Exception):
53
+ error_txt = ": " + err_str + "\n" if (err_str := str(error)) else "\n"
54
+ else:
55
+ raise RuntimeError(
56
+ f"Unexpected type `{type(error)}`, expected an Exception"
57
+ )
58
+
59
+ string = f"{indent}{name}: {type(error).__name__}" + error_txt
60
+ return string
61
+
62
+ def format_error_string(self, indent="") -> str:
63
+ if not isinstance(self._errors, dict) and not isinstance(self._errors, str):
64
+ raise RuntimeError(
65
+ f"Unexpected type `{type(self._errors)}` " "expected `str` or `dict`"
66
+ )
67
+
68
+ if isinstance(self._errors, str):
69
+ return " " + self._errors + "\n"
70
+
71
+ string = "\n"
72
+ for name, error in self._errors.items():
73
+ string += self._format_sub_errors(name, error, indent=indent)
74
+ return string
21
75
 
22
76
  def __str__(self) -> str:
23
- return "\n".join(self.lines)
77
+ return self.format_error_string(indent="")
24
78
 
25
79
 
26
80
  async def wait_for_connection(**coros: Awaitable[None]):
27
- """Call many underlying signals, accumulating `NotConnected` exceptions
81
+ """Call many underlying signals, accumulating exceptions and returning them
28
82
 
29
- Raises
30
- ------
31
- `NotConnected` if cancelled
83
+ Expected kwargs should be a mapping of names to coroutine tasks to execute.
32
84
  """
33
- ts = {k: asyncio.create_task(c) for (k, c) in coros.items()} # type: ignore
34
- try:
35
- done, pending = await asyncio.wait(ts.values())
36
- except asyncio.CancelledError:
37
- for t in ts.values():
38
- t.cancel()
39
- lines: List[str] = []
40
- for k, t in ts.items():
41
- try:
42
- await t
43
- except NotConnected as e:
44
- if len(e.lines) == 1:
45
- lines.append(f"{k}: {e.lines[0]}")
46
- else:
47
- lines.append(f"{k}:")
48
- lines += [f" {line}" for line in e.lines]
49
- raise NotConnected(*lines)
50
- else:
51
- # Wait for everything to foreground the exceptions
52
- for f in list(done) + list(pending):
53
- await f
85
+ results = await asyncio.gather(*coros.values(), return_exceptions=True)
86
+ exceptions = {}
87
+
88
+ for name, result in zip(coros, results):
89
+ if isinstance(result, Exception):
90
+ exceptions[name] = result
91
+ if not isinstance(result, NotConnected):
92
+ logging.exception(
93
+ f"device `{name}` raised unexpected exception "
94
+ f"{type(result).__name__}",
95
+ exc_info=result,
96
+ )
97
+
98
+ if exceptions:
99
+ raise NotConnected(exceptions)
54
100
 
55
101
 
56
102
  def get_dtype(typ: Type) -> Optional[np.dtype]:
@@ -86,7 +132,7 @@ def get_unique(values: Dict[str, T], types: str) -> T:
86
132
 
87
133
 
88
134
  async def merge_gathered_dicts(
89
- coros: Iterable[Awaitable[Dict[str, T]]]
135
+ coros: Iterable[Awaitable[Dict[str, T]]],
90
136
  ) -> Dict[str, T]:
91
137
  """Merge dictionaries produced by a sequence of coroutines.
92
138
 
@@ -102,3 +148,20 @@ async def merge_gathered_dicts(
102
148
 
103
149
  async def gather_list(coros: Iterable[Awaitable[T]]) -> List[T]:
104
150
  return await asyncio.gather(*coros)
151
+
152
+
153
+ def in_micros(t: float) -> int:
154
+ """
155
+ Converts between a positive number of seconds and an equivalent
156
+ number of microseconds.
157
+
158
+ Args:
159
+ t (float): A time in seconds
160
+ Raises:
161
+ ValueError: if t < 0
162
+ Returns:
163
+ t (int): A time in microseconds, rounded up to the nearest whole microsecond,
164
+ """
165
+ if t < 0:
166
+ raise ValueError(f"Expected a positive time in seconds, got {t!r}")
167
+ return int(np.ceil(t * 1e6))
@@ -1,5 +1,5 @@
1
+ import logging
1
2
  import sys
2
- from asyncio import CancelledError
3
3
  from dataclasses import dataclass
4
4
  from enum import Enum
5
5
  from typing import Any, Dict, Optional, Sequence, Type, Union
@@ -8,6 +8,7 @@ from aioca import (
8
8
  FORMAT_CTRL,
9
9
  FORMAT_RAW,
10
10
  FORMAT_TIME,
11
+ CANothing,
11
12
  Subscription,
12
13
  caget,
13
14
  camonitor,
@@ -18,7 +19,6 @@ from bluesky.protocols import Descriptor, Dtype, Reading
18
19
  from epicscorelibs.ca import dbr
19
20
 
20
21
  from ophyd_async.core import (
21
- NotConnected,
22
22
  ReadingValueCallback,
23
23
  SignalBackend,
24
24
  T,
@@ -26,6 +26,9 @@ from ophyd_async.core import (
26
26
  get_unique,
27
27
  wait_for_connection,
28
28
  )
29
+ from ophyd_async.core.utils import DEFAULT_TIMEOUT, NotConnected
30
+
31
+ from .common import get_supported_enum_class
29
32
 
30
33
  dbr_to_dtype: Dict[Dbr, Dtype] = {
31
34
  dbr.DBR_STRING: "string",
@@ -49,14 +52,14 @@ class CaConverter:
49
52
  return value
50
53
 
51
54
  def reading(self, value: AugmentedValue):
52
- return dict(
53
- value=self.value(value),
54
- timestamp=value.timestamp,
55
- alarm_severity=-1 if value.severity > 2 else value.severity,
56
- )
55
+ return {
56
+ "value": self.value(value),
57
+ "timestamp": value.timestamp,
58
+ "alarm_severity": -1 if value.severity > 2 else value.severity,
59
+ }
57
60
 
58
61
  def descriptor(self, source: str, value: AugmentedValue) -> Descriptor:
59
- return dict(source=source, dtype=dbr_to_dtype[value.datatype], shape=[])
62
+ return {"source": source, "dtype": dbr_to_dtype[value.datatype], "shape": []}
60
63
 
61
64
 
62
65
  class CaLongStrConverter(CaConverter):
@@ -71,7 +74,7 @@ class CaLongStrConverter(CaConverter):
71
74
 
72
75
  class CaArrayConverter(CaConverter):
73
76
  def descriptor(self, source: str, value: AugmentedValue) -> Descriptor:
74
- return dict(source=source, dtype="array", shape=[len(value)])
77
+ return {"source": source, "dtype": "array", "shape": [len(value)]}
75
78
 
76
79
 
77
80
  @dataclass
@@ -89,9 +92,7 @@ class CaEnumConverter(CaConverter):
89
92
 
90
93
  def descriptor(self, source: str, value: AugmentedValue) -> Descriptor:
91
94
  choices = [e.value for e in self.enum_class]
92
- return dict(
93
- source=source, dtype="string", shape=[], choices=choices
94
- ) # type: ignore
95
+ return {"source": source, "dtype": "string", "shape": [], "choices": choices}
95
96
 
96
97
 
97
98
  class DisconnectedCaConverter(CaConverter):
@@ -137,19 +138,7 @@ def make_converter(
137
138
  pv_choices = get_unique(
138
139
  {k: tuple(v.enums) for k, v in values.items()}, "choices"
139
140
  )
140
- if datatype:
141
- if not issubclass(datatype, Enum):
142
- raise TypeError(f"{pv} has type Enum not {datatype.__name__}")
143
- if not issubclass(datatype, str):
144
- raise TypeError(f"{pv} has type Enum but doesn't inherit from String")
145
- choices = tuple(v.value for v in datatype)
146
- if set(choices) != set(pv_choices):
147
- raise TypeError(f"{pv} has choices {pv_choices} not {choices}")
148
- enum_class = datatype
149
- else:
150
- enum_class = Enum( # type: ignore
151
- "GeneratedChoices", {x: x for x in pv_choices}, type=str
152
- )
141
+ enum_class = get_supported_enum_class(pv, datatype, pv_choices)
153
142
  return CaEnumConverter(dbr.DBR_STRING, None, enum_class)
154
143
  else:
155
144
  value = list(values.values())[0]
@@ -181,26 +170,31 @@ class CaSignalBackend(SignalBackend[T]):
181
170
  self.write_pv = write_pv
182
171
  self.initial_values: Dict[str, AugmentedValue] = {}
183
172
  self.converter: CaConverter = DisconnectedCaConverter(None, None)
184
- self.source = f"ca://{self.read_pv}"
185
173
  self.subscription: Optional[Subscription] = None
186
174
 
187
- async def _store_initial_value(self, pv):
175
+ def source(self, name: str):
176
+ return f"ca://{self.read_pv}"
177
+
178
+ async def _store_initial_value(self, pv, timeout: float = DEFAULT_TIMEOUT):
188
179
  try:
189
- self.initial_values[pv] = await caget(pv, format=FORMAT_CTRL, timeout=None)
190
- except CancelledError:
191
- raise NotConnected(self.source)
180
+ self.initial_values[pv] = await caget(
181
+ pv, format=FORMAT_CTRL, timeout=timeout
182
+ )
183
+ except CANothing as exc:
184
+ logging.debug(f"signal ca://{pv} timed out")
185
+ raise NotConnected(f"ca://{pv}") from exc
192
186
 
193
- async def connect(self):
187
+ async def connect(self, timeout: float = DEFAULT_TIMEOUT):
194
188
  _use_pyepics_context_if_imported()
195
189
  if self.read_pv != self.write_pv:
196
190
  # Different, need to connect both
197
191
  await wait_for_connection(
198
- read_pv=self._store_initial_value(self.read_pv),
199
- write_pv=self._store_initial_value(self.write_pv),
192
+ read_pv=self._store_initial_value(self.read_pv, timeout=timeout),
193
+ write_pv=self._store_initial_value(self.write_pv, timeout=timeout),
200
194
  )
201
195
  else:
202
196
  # The same, so only need to connect one
203
- await self._store_initial_value(self.read_pv)
197
+ await self._store_initial_value(self.read_pv, timeout=timeout)
204
198
  self.converter = make_converter(self.datatype, self.initial_values)
205
199
 
206
200
  async def put(self, value: Optional[T], wait=True, timeout=None):
@@ -224,9 +218,9 @@ class CaSignalBackend(SignalBackend[T]):
224
218
  timeout=None,
225
219
  )
226
220
 
227
- async def get_descriptor(self) -> Descriptor:
221
+ async def get_descriptor(self, source: str) -> Descriptor:
228
222
  value = await self._caget(FORMAT_CTRL)
229
- return self.converter.descriptor(self.source, value)
223
+ return self.converter.descriptor(source, value)
230
224
 
231
225
  async def get_reading(self) -> Reading:
232
226
  value = await self._caget(FORMAT_TIME)
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import atexit
3
- from asyncio import CancelledError
3
+ import logging
4
+ import time
4
5
  from dataclasses import dataclass
5
6
  from enum import Enum
6
7
  from typing import Any, Dict, List, Optional, Sequence, Type, Union
@@ -10,7 +11,6 @@ from p4p import Value
10
11
  from p4p.client.asyncio import Context, Subscription
11
12
 
12
13
  from ophyd_async.core import (
13
- NotConnected,
14
14
  ReadingValueCallback,
15
15
  SignalBackend,
16
16
  T,
@@ -18,6 +18,9 @@ from ophyd_async.core import (
18
18
  get_unique,
19
19
  wait_for_connection,
20
20
  )
21
+ from ophyd_async.core.utils import DEFAULT_TIMEOUT, NotConnected
22
+
23
+ from .common import get_supported_enum_class
21
24
 
22
25
  # https://mdavidsaver.github.io/p4p/values.html
23
26
  specifier_to_dtype: Dict[str, Dtype] = {
@@ -46,15 +49,15 @@ class PvaConverter:
46
49
  def reading(self, value):
47
50
  ts = value["timeStamp"]
48
51
  sv = value["alarm"]["severity"]
49
- return dict(
50
- value=self.value(value),
51
- timestamp=ts["secondsPastEpoch"] + ts["nanoseconds"] * 1e-9,
52
- alarm_severity=-1 if sv > 2 else sv,
53
- )
52
+ return {
53
+ "value": self.value(value),
54
+ "timestamp": ts["secondsPastEpoch"] + ts["nanoseconds"] * 1e-9,
55
+ "alarm_severity": -1 if sv > 2 else sv,
56
+ }
54
57
 
55
58
  def descriptor(self, source: str, value) -> Descriptor:
56
59
  dtype = specifier_to_dtype[value.type().aspy("value")]
57
- return dict(source=source, dtype=dtype, shape=[])
60
+ return {"source": source, "dtype": dtype, "shape": []}
58
61
 
59
62
  def metadata_fields(self) -> List[str]:
60
63
  """
@@ -71,7 +74,7 @@ class PvaConverter:
71
74
 
72
75
  class PvaArrayConverter(PvaConverter):
73
76
  def descriptor(self, source: str, value) -> Descriptor:
74
- return dict(source=source, dtype="array", shape=[len(value["value"])])
77
+ return {"source": source, "dtype": "array", "shape": [len(value["value"])]}
75
78
 
76
79
 
77
80
  class PvaNDArrayConverter(PvaConverter):
@@ -95,7 +98,7 @@ class PvaNDArrayConverter(PvaConverter):
95
98
 
96
99
  def descriptor(self, source: str, value) -> Descriptor:
97
100
  dims = self._get_dimensions(value)
98
- return dict(source=source, dtype="array", shape=dims)
101
+ return {"source": source, "dtype": "array", "shape": dims}
99
102
 
100
103
  def write_value(self, value):
101
104
  # No clear use-case for writing directly to an NDArray, and some
@@ -119,9 +122,7 @@ class PvaEnumConverter(PvaConverter):
119
122
 
120
123
  def descriptor(self, source: str, value) -> Descriptor:
121
124
  choices = [e.value for e in self.enum_class]
122
- return dict(
123
- source=source, dtype="string", shape=[], choices=choices
124
- ) # type: ignore
125
+ return {"source": source, "dtype": "string", "shape": [], "choices": choices}
125
126
 
126
127
 
127
128
  class PvaEnumBoolConverter(PvaConverter):
@@ -129,7 +130,7 @@ class PvaEnumBoolConverter(PvaConverter):
129
130
  return value["value"]["index"]
130
131
 
131
132
  def descriptor(self, source: str, value) -> Descriptor:
132
- return dict(source=source, dtype="integer", shape=[])
133
+ return {"source": source, "dtype": "integer", "shape": []}
133
134
 
134
135
 
135
136
  class PvaTableConverter(PvaConverter):
@@ -138,7 +139,33 @@ class PvaTableConverter(PvaConverter):
138
139
 
139
140
  def descriptor(self, source: str, value) -> Descriptor:
140
141
  # This is wrong, but defer until we know how to actually describe a table
141
- return dict(source=source, dtype="object", shape=[]) # type: ignore
142
+ return {"source": source, "dtype": "object", "shape": []} # type: ignore
143
+
144
+
145
+ class PvaDictConverter(PvaConverter):
146
+ def reading(self, value):
147
+ ts = time.time()
148
+ value = value.todict()
149
+ # Alarm severity is vacuously 0 for a table
150
+ return {"value": value, "timestamp": ts, "alarm_severity": 0}
151
+
152
+ def value(self, value: Value):
153
+ return value.todict()
154
+
155
+ def descriptor(self, source: str, value) -> Descriptor:
156
+ raise NotImplementedError("Describing Dict signals not currently supported")
157
+
158
+ def metadata_fields(self) -> List[str]:
159
+ """
160
+ Fields to request from PVA for metadata.
161
+ """
162
+ return []
163
+
164
+ def value_fields(self) -> List[str]:
165
+ """
166
+ Fields to request from PVA for the value.
167
+ """
168
+ return []
142
169
 
143
170
 
144
171
  class DisconnectedPvaConverter(PvaConverter):
@@ -149,7 +176,9 @@ class DisconnectedPvaConverter(PvaConverter):
149
176
  def make_converter(datatype: Optional[Type], values: Dict[str, Any]) -> PvaConverter:
150
177
  pv = list(values)[0]
151
178
  typeid = get_unique({k: v.getID() for k, v in values.items()}, "typeids")
152
- typ = get_unique({k: type(v["value"]) for k, v in values.items()}, "value types")
179
+ typ = get_unique(
180
+ {k: type(v.get("value")) for k, v in values.items()}, "value types"
181
+ )
153
182
  if "NTScalarArray" in typeid and typ == list:
154
183
  # Waveform of strings, check we wanted this
155
184
  if datatype and datatype != Sequence[str]:
@@ -185,24 +214,15 @@ def make_converter(datatype: Optional[Type], values: Dict[str, Any]) -> PvaConve
185
214
  pv_choices = get_unique(
186
215
  {k: tuple(v["value"]["choices"]) for k, v in values.items()}, "choices"
187
216
  )
188
- if datatype:
189
- if not issubclass(datatype, Enum):
190
- raise TypeError(f"{pv} has type Enum not {datatype.__name__}")
191
- choices = tuple(v.value for v in datatype)
192
- if set(choices) != set(pv_choices):
193
- raise TypeError(f"{pv} has choices {pv_choices} not {choices}")
194
- enum_class = datatype
195
- else:
196
- enum_class = Enum( # type: ignore
197
- "GeneratedChoices", {x or "_": x for x in pv_choices}, type=str
198
- )
199
- return PvaEnumConverter(enum_class)
217
+ return PvaEnumConverter(get_supported_enum_class(pv, datatype, pv_choices))
200
218
  elif "NTScalar" in typeid:
201
219
  if datatype and not issubclass(typ, datatype):
202
220
  raise TypeError(f"{pv} has type {typ.__name__} not {datatype.__name__}")
203
221
  return PvaConverter()
204
222
  elif "NTTable" in typeid:
205
223
  return PvaTableConverter()
224
+ elif "structure" in typeid:
225
+ return PvaDictConverter()
206
226
  else:
207
227
  raise TypeError(f"{pv}: Unsupported typeid {typeid}")
208
228
 
@@ -216,9 +236,12 @@ class PvaSignalBackend(SignalBackend[T]):
216
236
  self.write_pv = write_pv
217
237
  self.initial_values: Dict[str, Any] = {}
218
238
  self.converter: PvaConverter = DisconnectedPvaConverter()
219
- self.source = f"pva://{self.read_pv}"
220
239
  self.subscription: Optional[Subscription] = None
221
240
 
241
+ @property
242
+ def source(self, name: str):
243
+ return f"pva://{self.read_pv}"
244
+
222
245
  @property
223
246
  def ctxt(self) -> Context:
224
247
  if PvaSignalBackend._ctxt is None:
@@ -233,22 +256,25 @@ class PvaSignalBackend(SignalBackend[T]):
233
256
 
234
257
  return PvaSignalBackend._ctxt
235
258
 
236
- async def _store_initial_value(self, pv):
259
+ async def _store_initial_value(self, pv, timeout: float = DEFAULT_TIMEOUT):
237
260
  try:
238
- self.initial_values[pv] = await self.ctxt.get(pv)
239
- except CancelledError:
240
- raise NotConnected(self.source)
261
+ self.initial_values[pv] = await asyncio.wait_for(
262
+ self.ctxt.get(pv), timeout=timeout
263
+ )
264
+ except asyncio.TimeoutError as exc:
265
+ logging.debug(f"signal pva://{pv} timed out", exc_info=True)
266
+ raise NotConnected(f"pva://{pv}") from exc
241
267
 
242
- async def connect(self):
268
+ async def connect(self, timeout: float = DEFAULT_TIMEOUT):
243
269
  if self.read_pv != self.write_pv:
244
270
  # Different, need to connect both
245
271
  await wait_for_connection(
246
- read_pv=self._store_initial_value(self.read_pv),
247
- write_pv=self._store_initial_value(self.write_pv),
272
+ read_pv=self._store_initial_value(self.read_pv, timeout=timeout),
273
+ write_pv=self._store_initial_value(self.write_pv, timeout=timeout),
248
274
  )
249
275
  else:
250
276
  # The same, so only need to connect one
251
- await self._store_initial_value(self.read_pv)
277
+ await self._store_initial_value(self.read_pv, timeout=timeout)
252
278
  self.converter = make_converter(self.datatype, self.initial_values)
253
279
 
254
280
  async def put(self, value: Optional[T], wait=True, timeout=None):
@@ -256,12 +282,20 @@ class PvaSignalBackend(SignalBackend[T]):
256
282
  write_value = self.initial_values[self.write_pv]
257
283
  else:
258
284
  write_value = self.converter.write_value(value)
259
- coro = self.ctxt.put(self.write_pv, dict(value=write_value), wait=wait)
260
- await asyncio.wait_for(coro, timeout)
285
+ coro = self.ctxt.put(self.write_pv, {"value": write_value}, wait=wait)
286
+ try:
287
+ await asyncio.wait_for(coro, timeout)
288
+ except asyncio.TimeoutError as exc:
289
+ logging.debug(
290
+ f"signal pva://{self.write_pv} timed out \
291
+ put value: {write_value}",
292
+ exc_info=True,
293
+ )
294
+ raise NotConnected(f"pva://{self.write_pv}") from exc
261
295
 
262
- async def get_descriptor(self) -> Descriptor:
296
+ async def get_descriptor(self, source: str) -> Descriptor:
263
297
  value = await self.ctxt.get(self.read_pv)
264
- return self.converter.descriptor(self.source, value)
298
+ return self.converter.descriptor(source, value)
265
299
 
266
300
  def _pva_request_string(self, fields: List[str]) -> str:
267
301
  """