ophyd-async 0.13.4__py3-none-any.whl → 0.13.6__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 (34) hide show
  1. ophyd_async/_version.py +2 -2
  2. ophyd_async/core/__init__.py +24 -2
  3. ophyd_async/core/_derived_signal_backend.py +2 -1
  4. ophyd_async/core/_detector.py +2 -2
  5. ophyd_async/core/_device.py +9 -9
  6. ophyd_async/core/_enums.py +5 -0
  7. ophyd_async/core/_mock_signal_backend.py +7 -3
  8. ophyd_async/core/_signal.py +34 -38
  9. ophyd_async/core/_signal_backend.py +3 -1
  10. ophyd_async/core/_status.py +2 -2
  11. ophyd_async/core/_utils.py +11 -11
  12. ophyd_async/epics/adcore/_utils.py +4 -4
  13. ophyd_async/epics/core/_aioca.py +2 -2
  14. ophyd_async/epics/core/_p4p.py +2 -2
  15. ophyd_async/epics/motor.py +28 -7
  16. ophyd_async/epics/pmac/_pmac_trajectory_generation.py +3 -0
  17. ophyd_async/fastcs/panda/_block.py +10 -9
  18. ophyd_async/sim/_motor.py +4 -2
  19. ophyd_async/sim/_stage.py +14 -4
  20. ophyd_async/tango/core/__init__.py +17 -3
  21. ophyd_async/tango/core/_signal.py +18 -22
  22. ophyd_async/tango/core/_tango_transport.py +407 -239
  23. ophyd_async/tango/core/_utils.py +9 -0
  24. ophyd_async/tango/demo/_mover.py +1 -2
  25. ophyd_async/tango/testing/__init__.py +2 -1
  26. ophyd_async/tango/testing/_one_of_everything.py +13 -5
  27. ophyd_async/tango/testing/_test_config.py +11 -0
  28. ophyd_async/testing/_assert.py +2 -2
  29. ophyd_async/testing/_mock_signal_utils.py +5 -2
  30. {ophyd_async-0.13.4.dist-info → ophyd_async-0.13.6.dist-info}/METADATA +2 -36
  31. {ophyd_async-0.13.4.dist-info → ophyd_async-0.13.6.dist-info}/RECORD +34 -33
  32. {ophyd_async-0.13.4.dist-info → ophyd_async-0.13.6.dist-info}/WHEEL +0 -0
  33. {ophyd_async-0.13.4.dist-info → ophyd_async-0.13.6.dist-info}/licenses/LICENSE +0 -0
  34. {ophyd_async-0.13.4.dist-info → ophyd_async-0.13.6.dist-info}/top_level.txt +0 -0
@@ -3,15 +3,24 @@ import functools
3
3
  import logging
4
4
  import time
5
5
  from abc import abstractmethod
6
- from collections.abc import Callable, Coroutine
7
- from enum import Enum
8
- from typing import Any, ParamSpec, TypeVar, cast
6
+ from collections.abc import Callable, Coroutine, Sequence
7
+ from typing import (
8
+ Any,
9
+ ParamSpec,
10
+ TypeVar,
11
+ cast,
12
+ get_args,
13
+ get_origin,
14
+ )
9
15
 
10
16
  import numpy as np
17
+ import numpy.typing as npt
11
18
  from bluesky.protocols import Reading
12
- from event_model import DataKey
19
+ from event_model import DataKey, Limits, LimitsRange
20
+ from event_model.documents.event_descriptor import RdsRange
13
21
  from tango import (
14
22
  AttrDataFormat,
23
+ AttributeInfo,
15
24
  AttributeInfoEx,
16
25
  CmdArgType,
17
26
  CommandInfo,
@@ -19,6 +28,7 @@ from tango import (
19
28
  DeviceProxy,
20
29
  DevState,
21
30
  EventType,
31
+ GreenMode,
22
32
  )
23
33
  from tango.asyncio import DeviceProxy as AsyncDeviceProxy
24
34
  from tango.asyncio_executor import (
@@ -26,19 +36,23 @@ from tango.asyncio_executor import (
26
36
  get_global_executor,
27
37
  set_global_executor,
28
38
  )
29
- from tango.utils import is_array, is_binary, is_bool, is_float, is_int, is_str
39
+ from tango.utils import is_binary, is_bool, is_float, is_int, is_str
30
40
 
31
41
  from ophyd_async.core import (
42
+ Array1D,
32
43
  AsyncStatus,
33
44
  Callback,
34
- NotConnected,
45
+ NotConnectedError,
35
46
  SignalBackend,
36
47
  SignalDatatypeT,
48
+ SignalMetadata,
37
49
  StrictEnum,
50
+ Table,
38
51
  get_dtype,
39
- get_unique,
52
+ make_datakey,
40
53
  wait_for_connection,
41
54
  )
55
+ from ophyd_async.tango.testing import TestConfig
42
56
 
43
57
  from ._converters import (
44
58
  TangoConverter,
@@ -47,7 +61,7 @@ from ._converters import (
47
61
  TangoEnumArrayConverter,
48
62
  TangoEnumConverter,
49
63
  )
50
- from ._utils import DevStateEnum, get_device_trl_and_attr
64
+ from ._utils import DevStateEnum, get_device_trl_and_attr, try_to_cast_as_float
51
65
 
52
66
  logger = logging.getLogger("ophyd_async")
53
67
 
@@ -73,28 +87,85 @@ def ensure_proper_executor(
73
87
  return wrapper
74
88
 
75
89
 
76
- def get_python_type(tango_type: CmdArgType) -> tuple[bool, object, str]:
90
+ class TangoLongStringTable(Table):
91
+ long: Array1D[np.int32]
92
+ string: Sequence[str]
93
+
94
+
95
+ class TangoDoubleStringTable(Table):
96
+ double: Array1D[np.float64]
97
+ string: Sequence[str]
98
+
99
+
100
+ def get_python_type(config: AttributeInfoEx | CommandInfo | TestConfig) -> object:
77
101
  """For converting between recieved tango types and python primatives."""
78
- array = is_array(tango_type)
102
+ tango_type = None
103
+ tango_format = None
104
+ if isinstance(config, AttributeInfoEx | AttributeInfo):
105
+ tango_type = config.data_type
106
+ tango_format = config.data_format
107
+ elif isinstance(config, CommandInfo):
108
+ read_character = get_command_character(config)
109
+ if read_character == CommandProxyReadCharacter.READ:
110
+ tango_type = config.out_type
111
+ else:
112
+ tango_type = config.in_type
113
+ elif isinstance(config, TestConfig):
114
+ tango_type = config.data_type
115
+ tango_format = config.data_format
116
+ else:
117
+ raise TypeError("Unrecognized Tango resource configuration")
118
+ if tango_format not in [
119
+ AttrDataFormat.SCALAR,
120
+ AttrDataFormat.SPECTRUM,
121
+ AttrDataFormat.IMAGE,
122
+ None,
123
+ ]:
124
+ raise TypeError("Unknown TangoFormat")
125
+
126
+ if tango_type is CmdArgType.DevVarLongStringArray:
127
+ return TangoLongStringTable
128
+ if tango_type is CmdArgType.DevVarDoubleStringArray:
129
+ return TangoDoubleStringTable
130
+
131
+ def _get_type(cls: type) -> object:
132
+ if tango_format == AttrDataFormat.SCALAR:
133
+ return cls
134
+ elif tango_format == AttrDataFormat.SPECTRUM:
135
+ if cls is str or issubclass(cls, StrictEnum):
136
+ return Sequence[cls]
137
+ return Array1D[cls]
138
+ elif tango_format == AttrDataFormat.IMAGE:
139
+ if cls is str or issubclass(cls, StrictEnum):
140
+ return Sequence[Sequence[str]]
141
+ return npt.NDArray[cls]
142
+ else:
143
+ return cls
144
+
79
145
  if is_int(tango_type, True):
80
- return array, int, "integer"
81
- if is_float(tango_type, True):
82
- return array, float, "number"
83
- if is_bool(tango_type, True):
84
- return array, bool, "integer"
85
- if is_str(tango_type, True):
86
- return array, str, "string"
87
- if is_binary(tango_type, True):
88
- return array, list[str], "string"
89
- if tango_type == CmdArgType.DevEnum:
90
- return array, Enum, "string"
91
- if tango_type == CmdArgType.DevState:
92
- return array, CmdArgType.DevState, "string"
93
- if tango_type == CmdArgType.DevUChar:
94
- return array, int, "integer"
95
- if tango_type == CmdArgType.DevVoid:
96
- return array, None, "string"
97
- raise TypeError("Unknown TangoType")
146
+ return _get_type(int)
147
+ elif is_float(tango_type, True):
148
+ return _get_type(float)
149
+ elif is_bool(tango_type, True):
150
+ return _get_type(bool)
151
+ elif is_str(tango_type, True):
152
+ return _get_type(str)
153
+ elif is_binary(tango_type, True):
154
+ return _get_type(str)
155
+ elif tango_type == CmdArgType.DevEnum:
156
+ if hasattr(config, "enum_labels"):
157
+ enum_dict = {label: str(label) for label in config.enum_labels}
158
+ return _get_type(StrictEnum("TangoEnum", enum_dict))
159
+ else:
160
+ return _get_type(int)
161
+ elif tango_type == CmdArgType.DevState:
162
+ return _get_type(DevStateEnum)
163
+ elif tango_type == CmdArgType.DevUChar:
164
+ return _get_type(int)
165
+ elif tango_type == CmdArgType.DevVoid:
166
+ return None
167
+ else:
168
+ raise TypeError(f"Unknown TangoType: {tango_type}")
98
169
 
99
170
 
100
171
  class TangoProxy:
@@ -202,49 +273,26 @@ class AttributeProxy(TangoProxy):
202
273
  async def put( # type: ignore
203
274
  self, value: object | None, wait: bool = True, timeout: float | None = None
204
275
  ) -> AsyncStatus | None:
276
+ if wait is False:
277
+ raise RuntimeWarning(
278
+ "wait=False is not supported in Tango."
279
+ "Simply don't await the status object."
280
+ )
205
281
  # TODO: remove the timeout from this as it is handled at the signal level
206
282
  value = self._converter.write_value(value)
207
- if wait:
208
- try:
209
-
210
- async def _write():
211
- return await self._proxy.write_attribute(self._name, value)
212
-
213
- task = asyncio.create_task(_write())
214
- await asyncio.wait_for(task, timeout)
215
- except TimeoutError as te:
216
- raise TimeoutError(f"{self._name} attr put failed: Timeout") from te
217
- except DevFailed as de:
218
- raise RuntimeError(
219
- f"{self._name} device failure: {de.args[0].desc}"
220
- ) from de
221
-
222
- else:
223
- rid = await self._proxy.write_attribute_asynch(self._name, value)
283
+ try:
224
284
 
225
- async def wait_for_reply(rd: int, to: float | None):
226
- start_time = time.time()
227
- while True:
228
- try:
229
- # I have to typehint proxy as tango.DeviceProxy because
230
- # tango.asyncio.DeviceProxy cannot be used as a typehint.
231
- # This means pyright will not be able to see that
232
- # write_attribute_reply is awaitable.
233
- await self._proxy.write_attribute_reply(rd) # type: ignore
234
- break
235
- except DevFailed as exc:
236
- if exc.args[0].reason == "API_AsynReplyNotArrived":
237
- await asyncio.sleep(A_BIT)
238
- if to and (time.time() - start_time > to):
239
- raise TimeoutError(
240
- f"{self._name} attr put failed: Timeout"
241
- ) from exc
242
- else:
243
- raise RuntimeError(
244
- f"{self._name} device failure: {exc.args[0].desc}"
245
- ) from exc
285
+ async def _write():
286
+ return await self._proxy.write_attribute(self._name, value)
246
287
 
247
- return AsyncStatus(wait_for_reply(rid, timeout))
288
+ task = asyncio.create_task(_write())
289
+ await asyncio.wait_for(task, timeout)
290
+ except TimeoutError as te:
291
+ raise TimeoutError(f"{self._name} attr put failed: Timeout") from te
292
+ except DevFailed as de:
293
+ raise RuntimeError(
294
+ f"{self._name} device failure: {de.args[0].desc}"
295
+ ) from de
248
296
 
249
297
  @ensure_proper_executor
250
298
  async def get_config(self) -> AttributeInfoEx: # type: ignore
@@ -264,6 +312,17 @@ class AttributeProxy(TangoProxy):
264
312
  def has_subscription(self) -> bool:
265
313
  return bool(self._callback)
266
314
 
315
+ @ensure_proper_executor
316
+ async def _subscribe_to_event(self):
317
+ if not self._eid:
318
+ self._eid = await self._proxy.subscribe_event(
319
+ self._name,
320
+ EventType.CHANGE_EVENT,
321
+ self._event_processor,
322
+ stateless=True,
323
+ green_mode=GreenMode.Asyncio,
324
+ )
325
+
267
326
  def subscribe_callback(self, callback: Callback | None):
268
327
  # If the attribute supports events, then we can subscribe to them
269
328
  # If the callback is not a callable, then we raise an error
@@ -272,14 +331,7 @@ class AttributeProxy(TangoProxy):
272
331
 
273
332
  self._callback = callback
274
333
  if self.support_events:
275
- """add user callback to CHANGE event subscription"""
276
- if not self._eid:
277
- self._eid = self._proxy.subscribe_event(
278
- self._name,
279
- EventType.CHANGE_EVENT,
280
- self._event_processor,
281
- green_mode=False,
282
- )
334
+ asyncio.create_task(self._subscribe_to_event())
283
335
  elif self._allow_polling:
284
336
  """start polling if no events supported"""
285
337
  if self._callback is not None:
@@ -303,8 +355,12 @@ class AttributeProxy(TangoProxy):
303
355
 
304
356
  def unsubscribe_callback(self):
305
357
  if self._eid:
306
- self._proxy.unsubscribe_event(self._eid, green_mode=False)
307
- self._eid = None
358
+ try:
359
+ self._proxy.unsubscribe_event(self._eid, green_mode=False)
360
+ except Exception as exc:
361
+ logger.warning(f"Could not unsubscribe from event: {exc}")
362
+ finally:
363
+ self._eid = None
308
364
  if self._poll_task:
309
365
  self._poll_task.cancel()
310
366
  self._poll_task = None
@@ -316,7 +372,8 @@ class AttributeProxy(TangoProxy):
316
372
  pass
317
373
  self._callback = None
318
374
 
319
- def _event_processor(self, event):
375
+ @ensure_proper_executor
376
+ async def _event_processor(self, event):
320
377
  if not event.err:
321
378
  reading = Reading(
322
379
  value=self._converter.value(event.attr_value.value),
@@ -339,8 +396,8 @@ class AttributeProxy(TangoProxy):
339
396
  # Initial reading
340
397
  if self._callback is not None:
341
398
  self._callback(last_reading)
342
- except Exception as e:
343
- raise RuntimeError(f"Could not poll the attribute: {e}") from e
399
+ except Exception as exc:
400
+ raise RuntimeError(f"Could not poll the attribute: {exc}") from exc
344
401
 
345
402
  try:
346
403
  # If the value is a number, we can check for changes
@@ -396,8 +453,8 @@ class AttributeProxy(TangoProxy):
396
453
  else:
397
454
  break
398
455
  last_reading = reading.copy()
399
- except Exception as e:
400
- raise RuntimeError(f"Could not poll the attribute: {e}") from e
456
+ except Exception as exc:
457
+ raise RuntimeError(f"Could not poll the attribute: {exc}") from exc
401
458
 
402
459
  def set_polling(
403
460
  self,
@@ -413,10 +470,46 @@ class AttributeProxy(TangoProxy):
413
470
  self._rel_change = rel_change
414
471
 
415
472
 
473
+ class CommandProxyReadCharacter(StrictEnum):
474
+ """Enum to carry the read/write character of the CommandProxy."""
475
+
476
+ READ = "READ"
477
+ WRITE = "WRITE"
478
+ READ_WRITE = "READ_WRITE"
479
+ EXECUTE = "EXECUTE"
480
+
481
+
482
+ def get_command_character(config: CommandInfo) -> CommandProxyReadCharacter:
483
+ """Return the command character for the given command config."""
484
+ in_type = config.in_type
485
+ out_type = config.out_type
486
+ if in_type == CmdArgType.DevVoid and out_type != CmdArgType.DevVoid:
487
+ read_character = CommandProxyReadCharacter.READ
488
+ elif in_type != CmdArgType.DevVoid and out_type == CmdArgType.DevVoid:
489
+ read_character = CommandProxyReadCharacter.WRITE
490
+ elif in_type == CmdArgType.DevVoid and out_type == CmdArgType.DevVoid:
491
+ read_character = CommandProxyReadCharacter.EXECUTE
492
+ else:
493
+ read_character = CommandProxyReadCharacter.READ_WRITE
494
+ return read_character
495
+
496
+
416
497
  class CommandProxy(TangoProxy):
417
498
  """Tango proxy for commands."""
418
499
 
419
- _last_reading: Reading = Reading(value=None, timestamp=0, alarm_severity=0)
500
+ _last_reading: Reading
501
+ _last_w_value: Any
502
+ _config: CommandInfo
503
+ _read_character: CommandProxyReadCharacter
504
+ device_proxy: DeviceProxy
505
+ name: str
506
+
507
+ def __init__(self, device_proxy: DeviceProxy, name: str):
508
+ super().__init__(device_proxy, name)
509
+ self._last_reading = Reading(value=None, timestamp=0, alarm_severity=0)
510
+ self.device_proxy = device_proxy
511
+ self.name = name
512
+ self._last_w_value = None
420
513
 
421
514
  def subscribe_callback(self, callback: Callback | None) -> None:
422
515
  raise NotImplementedError("Cannot subscribe to commands")
@@ -425,78 +518,59 @@ class CommandProxy(TangoProxy):
425
518
  raise NotImplementedError("Cannot unsubscribe from commands")
426
519
 
427
520
  async def get(self) -> object:
428
- return self._last_reading["value"]
521
+ if self._read_character == CommandProxyReadCharacter.READ_WRITE:
522
+ return self._last_reading["value"]
523
+ elif self._read_character == CommandProxyReadCharacter.READ:
524
+ await self.put(value=None, wait=True, timeout=None)
525
+ return self._last_reading["value"]
429
526
 
430
527
  async def get_w_value(self) -> object:
431
- return self._last_reading["value"]
528
+ return self._last_w_value
432
529
 
433
530
  async def connect(self) -> None:
434
- pass
531
+ self._config = await self.device_proxy.get_command_config(self.name)
532
+ self._read_character = get_command_character(self._config)
435
533
 
436
534
  @ensure_proper_executor
437
535
  async def put( # type: ignore
438
536
  self, value: object | None, wait: bool = True, timeout: float | None = None
439
537
  ) -> AsyncStatus | None:
538
+ if wait is False:
539
+ raise RuntimeError(
540
+ "wait=False is not supported in Tango."
541
+ " Simply don't await the status object."
542
+ )
440
543
  value = self._converter.write_value(value)
441
- if wait:
442
- try:
443
-
444
- async def _put():
445
- return await self._proxy.command_inout(self._name, value)
446
-
447
- task = asyncio.create_task(_put())
448
- val = await asyncio.wait_for(task, timeout)
449
- self._last_reading = Reading(
450
- value=self._converter.value(val),
451
- timestamp=time.time(),
452
- alarm_severity=0,
453
- )
454
- except TimeoutError as te:
455
- raise TimeoutError(f"{self._name} command failed: Timeout") from te
456
- except DevFailed as de:
457
- raise RuntimeError(
458
- f"{self._name} device failure: {de.args[0].desc}"
459
- ) from de
460
-
461
- else:
462
- rid = self._proxy.command_inout_asynch(self._name, value)
544
+ try:
463
545
 
464
- async def wait_for_reply(rd: int, to: float | None):
465
- start_time = time.time()
466
- while True:
467
- try:
468
- reply_value = self._converter.value(
469
- self._proxy.command_inout_reply(rd)
470
- )
471
- self._last_reading = Reading(
472
- value=reply_value, timestamp=time.time(), alarm_severity=0
473
- )
474
- break
475
- except DevFailed as de_exc:
476
- if de_exc.args[0].reason == "API_AsynReplyNotArrived":
477
- await asyncio.sleep(A_BIT)
478
- if to and time.time() - start_time > to:
479
- raise TimeoutError(
480
- "Timeout while waiting for command reply"
481
- ) from de_exc
482
- else:
483
- raise RuntimeError(
484
- f"{self._name} device failure: {de_exc.args[0].desc}"
485
- ) from de_exc
546
+ async def _put():
547
+ return await self._proxy.command_inout(self._name, value)
486
548
 
487
- return AsyncStatus(wait_for_reply(rid, timeout))
549
+ task = asyncio.create_task(_put())
550
+ val = await asyncio.wait_for(task, timeout)
551
+ self._last_w_value = value
552
+ self._last_reading = Reading(
553
+ value=self._converter.value(val),
554
+ timestamp=time.time(),
555
+ alarm_severity=0,
556
+ )
557
+ except TimeoutError as te:
558
+ raise TimeoutError(f"{self._name} command failed: Timeout") from te
559
+ except DevFailed as de:
560
+ raise RuntimeError(
561
+ f"{self._name} device failure: {de.args[0].desc}"
562
+ ) from de
488
563
 
489
564
  @ensure_proper_executor
490
565
  async def get_config(self) -> CommandInfo: # type: ignore
491
566
  return await self._proxy.get_command_config(self._name)
492
567
 
493
568
  async def get_reading(self) -> Reading:
494
- reading = Reading(
495
- value=self._last_reading["value"],
496
- timestamp=self._last_reading["timestamp"],
497
- alarm_severity=self._last_reading.get("alarm_severity", 0),
498
- )
499
- return reading
569
+ if self._read_character == CommandProxyReadCharacter.READ:
570
+ await self.put(value=None, wait=True, timeout=None)
571
+ return self._last_reading
572
+ else:
573
+ return self._last_reading
500
574
 
501
575
  def set_polling(
502
576
  self,
@@ -518,101 +592,79 @@ def get_dtype_extended(datatype) -> object | None:
518
592
  return dtype
519
593
 
520
594
 
521
- def get_trl_descriptor(
522
- datatype: type | None,
595
+ def get_source_metadata(
523
596
  tango_resource: str,
524
- tr_configs: dict[str, AttributeInfoEx | CommandInfo],
525
- ) -> DataKey:
526
- """Create a descriptor from a tango resource locator."""
527
- tr_dtype = {}
528
- for tr_name, config in tr_configs.items():
597
+ tr_configs: dict[str, AttributeInfoEx],
598
+ ) -> SignalMetadata:
599
+ metadata = {}
600
+ for _, config in tr_configs.items():
529
601
  if isinstance(config, AttributeInfoEx):
530
- _, dtype, descr = get_python_type(config.data_type)
531
- tr_dtype[tr_name] = config.data_format, dtype, descr
532
- elif isinstance(config, CommandInfo):
533
- if (
534
- config.in_type != CmdArgType.DevVoid
535
- and config.out_type != CmdArgType.DevVoid
536
- and config.in_type != config.out_type
537
- ):
538
- raise RuntimeError(
539
- "Commands with different in and out dtypes are not supported"
540
- )
541
- array, dtype, descr = get_python_type(
542
- config.in_type
543
- if config.in_type != CmdArgType.DevVoid
544
- else config.out_type
602
+ alarm_info = config.alarms
603
+ _limits = Limits(
604
+ control=LimitsRange(
605
+ low=try_to_cast_as_float(config.min_value),
606
+ high=try_to_cast_as_float(config.max_value),
607
+ ),
608
+ warning=LimitsRange(
609
+ low=try_to_cast_as_float(alarm_info.min_warning),
610
+ high=try_to_cast_as_float(alarm_info.max_warning),
611
+ ),
612
+ alarm=LimitsRange(
613
+ low=try_to_cast_as_float(alarm_info.min_alarm),
614
+ high=try_to_cast_as_float(alarm_info.max_alarm),
615
+ ),
545
616
  )
546
- tr_dtype[tr_name] = (
547
- AttrDataFormat.SPECTRUM if array else AttrDataFormat.SCALAR,
548
- dtype,
549
- descr,
617
+
618
+ delta_t, delta_val = map(
619
+ try_to_cast_as_float, (alarm_info.delta_t, alarm_info.delta_val)
550
620
  )
551
- else:
552
- raise RuntimeError(f"Unknown config type: {type(config)}")
553
- tr_format, tr_dtype, tr_dtype_desc = get_unique(tr_dtype, "typeids")
554
-
555
- # tango commands are limited in functionality:
556
- # they do not have info about shape and Enum labels
557
- trl_config = list(tr_configs.values())[0]
558
- max_x: int = (
559
- trl_config.max_dim_x
560
- if hasattr(trl_config, "max_dim_x")
561
- else np.iinfo(np.int32).max
562
- )
563
- max_y: int = (
564
- trl_config.max_dim_y
565
- if hasattr(trl_config, "max_dim_y")
566
- else np.iinfo(np.int32).max
567
- )
568
- # is_attr = hasattr(trl_config, "enum_labels")
569
- # trl_choices = list(trl_config.enum_labels) if is_attr else []
570
-
571
- if tr_format in [AttrDataFormat.SPECTRUM, AttrDataFormat.IMAGE]:
572
- # This is an array
573
- if datatype:
574
- # Check we wanted an array of this type
575
- dtype = get_dtype_extended(datatype)
576
- if not dtype:
577
- raise TypeError(
578
- f"{tango_resource} has type [{tr_dtype}] not {datatype.__name__}"
621
+ if isinstance(delta_t, float) and isinstance(delta_val, float):
622
+ limits_rds = RdsRange(
623
+ time_difference=delta_t,
624
+ value_difference=delta_val,
625
+ )
626
+ _limits["rds"] = limits_rds
627
+ # if only one of the two is set
628
+ elif isinstance(delta_t, float) ^ isinstance(delta_val, float):
629
+ logger.warning(
630
+ f"Both delta_t and delta_val should be set for {tango_resource} "
631
+ f"but only one is set. "
632
+ f"delta_t: {alarm_info.delta_t}, delta_val: {alarm_info.delta_val}"
579
633
  )
580
- if dtype != tr_dtype:
581
- raise TypeError(f"{tango_resource} has type [{tr_dtype}] not [{dtype}]")
582
634
 
583
- if tr_format == AttrDataFormat.SPECTRUM:
584
- return DataKey(source=tango_resource, dtype="array", shape=[max_x])
585
- elif tr_format == AttrDataFormat.IMAGE:
586
- return DataKey(source=tango_resource, dtype="array", shape=[max_y, max_x])
635
+ _choices = list(config.enum_labels) if config.enum_labels else []
587
636
 
588
- else:
589
- if tr_dtype in (Enum, CmdArgType.DevState):
590
- # if tr_dtype == CmdArgType.DevState:
591
- # trl_choices = list(DevState.names.keys())
637
+ tr_dtype = get_python_type(config)
592
638
 
593
- if datatype:
594
- if not issubclass(datatype, Enum | DevState):
595
- raise TypeError(
596
- f"{tango_resource} has type Enum not {datatype.__name__}"
597
- )
598
- # if tr_dtype == Enum and is_attr:
599
- # if isinstance(datatype, DevState):
600
- # choices = tuple(v.name for v in datatype)
601
- # if set(choices) != set(trl_choices):
602
- # raise TypeError(
603
- # f"{tango_resource} has choices {trl_choices} "
604
- # f"not {choices}"
605
- # )
606
- return DataKey(source=tango_resource, dtype="string", shape=[])
607
- else:
608
- if datatype and not issubclass(tr_dtype, datatype):
609
- raise TypeError(
610
- f"{tango_resource} has type {tr_dtype.__name__} "
611
- f"not {datatype.__name__}"
612
- )
613
- return DataKey(source=tango_resource, dtype=tr_dtype_desc, shape=[])
639
+ if tr_dtype == CmdArgType.DevState:
640
+ _choices = list(DevState.names.keys())
614
641
 
615
- raise RuntimeError(f"Error getting descriptor for {tango_resource}")
642
+ _precision = None
643
+ if config.format:
644
+ try:
645
+ _precision = int(config.format.split(".")[1].split("f")[0])
646
+ except (ValueError, IndexError) as exc:
647
+ # If parsing config.format fails, _precision remains None.
648
+ logger.warning(
649
+ "Failed to parse precision from config.format: %s. Error: %s",
650
+ config.format,
651
+ exc,
652
+ )
653
+ no_limits = Limits(
654
+ control=LimitsRange(high=None, low=None),
655
+ warning=LimitsRange(high=None, low=None),
656
+ alarm=LimitsRange(high=None, low=None),
657
+ )
658
+ if _limits:
659
+ if _limits != no_limits:
660
+ metadata["limits"] = _limits
661
+ if _choices:
662
+ metadata["choices"] = _choices
663
+ if _precision:
664
+ metadata["precision"] = _precision
665
+ if config.unit:
666
+ metadata["units"] = config.unit
667
+ return SignalMetadata(**metadata)
616
668
 
617
669
 
618
670
  async def get_tango_trl(
@@ -625,7 +677,6 @@ async def get_tango_trl(
625
677
  trl_name = trl_name.lower()
626
678
  if device_proxy is None:
627
679
  device_proxy = await AsyncDeviceProxy(device_trl, timeout=timeout)
628
-
629
680
  # all attributes can be always accessible with low register
630
681
  if isinstance(device_proxy, DeviceProxy):
631
682
  all_attrs = [
@@ -703,7 +754,6 @@ class TangoSignalBackend(SignalBackend[SignalDatatypeT]):
703
754
  write_trl: self.device_proxy,
704
755
  }
705
756
  self.trl_configs: dict[str, AttributeInfoEx] = {}
706
- self.descriptor: DataKey = {} # type: ignore
707
757
  self._polling: tuple[bool, float, float | None, float | None] = (
708
758
  False,
709
759
  0.1,
@@ -730,20 +780,131 @@ class TangoSignalBackend(SignalBackend[SignalDatatypeT]):
730
780
  def source(self, name: str, read: bool) -> str:
731
781
  return self.read_trl if read else self.write_trl
732
782
 
783
+ def _type_match_ndarray(self, signal_type: type[SignalDatatypeT], tr_dtype: object):
784
+ tango_resource = self.source(name="", read=True)
785
+
786
+ def extract_dtype_param(dtype_arg):
787
+ if hasattr(dtype_arg, "__origin__") and dtype_arg.__origin__ is np.dtype:
788
+ inner = get_args(dtype_arg)
789
+ return inner[0] if inner else object
790
+ return dtype_arg
791
+
792
+ signal_dtype = extract_dtype_param(get_args(signal_type)[-1])
793
+ tr_dtype_arg = extract_dtype_param(get_args(tr_dtype)[-1])
794
+
795
+ try:
796
+ sdt = np.dtype(signal_dtype)
797
+ tdt = np.dtype(tr_dtype_arg)
798
+ except TypeError as exc:
799
+ raise TypeError(
800
+ f"Could not interpret array dtypes: {signal_dtype!r},"
801
+ f" {tr_dtype_arg!r} ({exc})"
802
+ ) from exc
803
+
804
+ if sdt != tdt:
805
+ raise TypeError(
806
+ f"{tango_resource} has type {tr_dtype!r}, expected {self.datatype!r}"
807
+ )
808
+
809
+ def _type_match_array(
810
+ self,
811
+ signal_type: type[SignalDatatypeT] | None,
812
+ tr_dtype: object,
813
+ tango_resource: str,
814
+ ):
815
+ # Always get a fresh resource string for the error context
816
+ tango_resource = self.source(name="", read=True)
817
+ if get_origin(signal_type) is Sequence and get_origin(tr_dtype) is Sequence:
818
+ sig_elem_type = get_args(signal_type)[0]
819
+ tr_elem_type = get_args(tr_dtype)[0]
820
+ self._type_match_scalar(sig_elem_type, tr_elem_type, tango_resource)
821
+ return
822
+ elif (
823
+ get_origin(signal_type) is np.ndarray and get_origin(tr_dtype) is np.ndarray
824
+ ):
825
+ if signal_type is None:
826
+ raise TypeError(
827
+ f"{tango_resource} has type {tr_dtype!r}, expected a non-None"
828
+ f" signal_type"
829
+ )
830
+ self._type_match_ndarray(signal_type, tr_dtype)
831
+ return
832
+ else:
833
+ raise TypeError(
834
+ tango_resource, "has type", str(signal_type), "which is not recognized"
835
+ )
836
+
837
+ def _type_match_scalar(
838
+ self,
839
+ signal_type: type[SignalDatatypeT] | None,
840
+ tr_dtype: object,
841
+ tango_resource: str,
842
+ ):
843
+ if signal_type is tr_dtype:
844
+ return
845
+ if isinstance(signal_type, type) and issubclass(signal_type, StrictEnum):
846
+ return
847
+ raise TypeError(
848
+ f"{tango_resource} has type {tr_dtype!r}, expected {self.datatype!r}"
849
+ )
850
+
851
+ def _verify_datatype_matches(self, config: AttributeInfoEx | CommandInfo):
852
+ """Verify that the datatype of the config matches the datatype of the signal."""
853
+ tr_dtype = get_python_type(config)
854
+ tango_resource = self.source(name="", read=True)
855
+ signal_type = self.datatype
856
+ if isinstance(config, AttributeInfoEx | AttributeInfo):
857
+ tr_format = config.data_format
858
+ if tr_format in [AttrDataFormat.SPECTRUM, AttrDataFormat.IMAGE]:
859
+ self._type_match_array(signal_type, tr_dtype, tango_resource)
860
+ elif tr_format is AttrDataFormat.SCALAR:
861
+ self._type_match_scalar(signal_type, tr_dtype, tango_resource)
862
+ elif isinstance(config, CommandInfo):
863
+ if (
864
+ config.in_type != CmdArgType.DevVoid
865
+ and config.out_type != CmdArgType.DevVoid
866
+ and config.in_type != config.out_type
867
+ ):
868
+ raise RuntimeError(
869
+ "Commands with different in and out dtypes are not supported"
870
+ )
871
+ if get_origin(tr_dtype) in [Sequence, np.ndarray]:
872
+ self._type_match_array(signal_type, tr_dtype, tango_resource)
873
+ else:
874
+ self._type_match_scalar(signal_type, tr_dtype, tango_resource)
875
+ else:
876
+ raise TypeError(
877
+ f"Unrecognized resource configuration: {config} "
878
+ f"for source {tango_resource}"
879
+ )
880
+
733
881
  async def _connect_and_store_config(self, trl: str, timeout: float) -> None:
734
882
  if not trl:
735
883
  raise RuntimeError(f"trl not set for {self}")
736
884
  try:
737
885
  self.proxies[trl] = await get_tango_trl(trl, self.proxies[trl], timeout)
738
886
  if self.proxies[trl] is None:
739
- raise NotConnected(f"Not connected to {trl}")
887
+ raise NotConnectedError(f"Not connected to {trl}")
740
888
  # Pyright does not believe that self.proxies[trl] is not None despite
741
889
  # the check above
742
890
  await self.proxies[trl].connect() # type: ignore
743
- self.trl_configs[trl] = await self.proxies[trl].get_config() # type: ignore
891
+ config = await self.proxies[trl].get_config() # type: ignore
892
+ self.trl_configs[trl] = config
893
+
894
+ # Perform signal verification
895
+ self._verify_datatype_matches(config)
896
+
897
+ if isinstance(config, AttributeInfoEx):
898
+ if (
899
+ config.data_type == CmdArgType.DevString
900
+ and config.data_format == AttrDataFormat.IMAGE
901
+ ):
902
+ raise TypeError(
903
+ "DevString IMAGE attributes are not supported by ophyd-async."
904
+ )
744
905
  self.proxies[trl].support_events = self.support_events # type: ignore
745
906
  except TimeoutError as ce:
746
- raise NotConnected(f"tango://{trl}") from ce
907
+ raise NotConnectedError(f"tango://{trl}") from ce
747
908
 
748
909
  async def connect(self, timeout: float) -> None:
749
910
  if not self.read_trl:
@@ -760,47 +921,54 @@ class TangoSignalBackend(SignalBackend[SignalDatatypeT]):
760
921
  self.proxies[self.read_trl].set_polling(*self._polling) # type: ignore
761
922
  self.converter = make_converter(self.trl_configs[self.read_trl], self.datatype)
762
923
  self.proxies[self.read_trl].set_converter(self.converter) # type: ignore
763
- self.descriptor = get_trl_descriptor(
764
- self.datatype, self.read_trl, self.trl_configs
765
- )
766
924
 
767
925
  async def put(self, value: SignalDatatypeT | None, wait=True, timeout=None) -> None:
768
926
  if self.proxies[self.write_trl] is None:
769
- raise NotConnected(f"Not connected to {self.write_trl}")
927
+ raise NotConnectedError(f"Not connected to {self.write_trl}")
770
928
  self.status = None
771
929
  put_status = await self.proxies[self.write_trl].put(value, wait, timeout) # type: ignore
772
930
  self.status = put_status
773
931
 
774
932
  async def get_datakey(self, source: str) -> DataKey:
775
- return self.descriptor
933
+ try:
934
+ value: Any = await self.proxies[source].get() # type: ignore
935
+ except AttributeError as ae:
936
+ raise NotConnectedError(f"Not connected to {source}") from ae
937
+ md = get_source_metadata(source, self.trl_configs)
938
+ return make_datakey(
939
+ self.datatype, # type: ignore
940
+ value,
941
+ source,
942
+ metadata=md,
943
+ )
776
944
 
777
945
  async def get_reading(self) -> Reading[SignalDatatypeT]:
778
946
  if self.proxies[self.read_trl] is None:
779
- raise NotConnected(f"Not connected to {self.read_trl}")
947
+ raise NotConnectedError(f"Not connected to {self.read_trl}")
780
948
  reading = await self.proxies[self.read_trl].get_reading() # type: ignore
781
949
  return reading
782
950
 
783
951
  async def get_value(self) -> SignalDatatypeT:
784
952
  if self.proxies[self.read_trl] is None:
785
- raise NotConnected(f"Not connected to {self.read_trl}")
953
+ raise NotConnectedError(f"Not connected to {self.read_trl}")
786
954
  proxy = self.proxies[self.read_trl]
787
955
  if proxy is None:
788
- raise NotConnected(f"Not connected to {self.read_trl}")
956
+ raise NotConnectedError(f"Not connected to {self.read_trl}")
789
957
  value = await proxy.get()
790
958
  return cast(SignalDatatypeT, value)
791
959
 
792
960
  async def get_setpoint(self) -> SignalDatatypeT:
793
961
  if self.proxies[self.write_trl] is None:
794
- raise NotConnected(f"Not connected to {self.write_trl}")
962
+ raise NotConnectedError(f"Not connected to {self.write_trl}")
795
963
  proxy = self.proxies[self.write_trl]
796
964
  if proxy is None:
797
- raise NotConnected(f"Not connected to {self.write_trl}")
965
+ raise NotConnectedError(f"Not connected to {self.write_trl}")
798
966
  w_value = await proxy.get_w_value()
799
967
  return cast(SignalDatatypeT, w_value)
800
968
 
801
969
  def set_callback(self, callback: Callback | None) -> None:
802
970
  if self.proxies[self.read_trl] is None:
803
- raise NotConnected(f"Not connected to {self.read_trl}")
971
+ raise NotConnectedError(f"Not connected to {self.read_trl}")
804
972
  if self.support_events is False and self._polling[0] is False:
805
973
  raise RuntimeError(
806
974
  f"Cannot set event for {self.read_trl}. "