bec-lib 4.1.7__tar.gz → 4.2.0__tar.gz

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 (156) hide show
  1. {bec_lib-4.1.7 → bec_lib-4.2.0}/PKG-INFO +1 -1
  2. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/endpoints.py +17 -0
  3. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/messages.py +172 -5
  4. {bec_lib-4.1.7 → bec_lib-4.2.0}/pyproject.toml +1 -1
  5. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_bec_messages.py +295 -1
  6. {bec_lib-4.1.7 → bec_lib-4.2.0}/.gitignore +0 -0
  7. {bec_lib-4.1.7 → bec_lib-4.2.0}/README.md +0 -0
  8. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/__init__.py +0 -0
  9. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/_print_versions.py +0 -0
  10. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/acl_login.py +0 -0
  11. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/actors.py +0 -0
  12. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/alarm_handler.py +0 -0
  13. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/atlas_models.py +0 -0
  14. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/bec_errors.py +0 -0
  15. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/bec_service.py +0 -0
  16. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/bec_yaml_loader.py +0 -0
  17. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/bl_state_manager.py +0 -0
  18. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/bl_states.py +0 -0
  19. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/builtin_actor_hli.py +0 -0
  20. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/callback_handler.py +0 -0
  21. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/channel_monitor.py +0 -0
  22. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/client.py +0 -0
  23. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/codecs.py +0 -0
  24. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/config_helper.py +0 -0
  25. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/config_values.py +0 -0
  26. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/configs/__init__.py +0 -0
  27. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/configs/demo_config.yaml +0 -0
  28. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/configs/openapi_schema.json +0 -0
  29. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/connector.py +0 -0
  30. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/dap_plugin_objects.py +0 -0
  31. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/dap_plugins.py +0 -0
  32. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/device.py +0 -0
  33. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/devicemanager.py +0 -0
  34. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/file_utils.py +0 -0
  35. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/live_scan_data.py +0 -0
  36. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/lmfit_serializer.py +0 -0
  37. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/logger.py +0 -0
  38. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/macro_update_handler.py +0 -0
  39. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/messaging_hooks.py +0 -0
  40. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/messaging_services.py +0 -0
  41. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/metadata_schema.py +0 -0
  42. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/numpy_encoder.py +0 -0
  43. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/observer.py +0 -0
  44. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/pdf_writer.py +0 -0
  45. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/plugin_helper.py +0 -0
  46. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/procedures/__init__.py +0 -0
  47. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/procedures/helper.py +0 -0
  48. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/procedures/hli.py +0 -0
  49. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/procedures/status.py +0 -0
  50. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/queue_items.py +0 -0
  51. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/redis_connector/__init__.py +0 -0
  52. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/redis_connector/constants.py +0 -0
  53. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/redis_connector/hli.py +0 -0
  54. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/redis_connector/managed_redis_connection.py +0 -0
  55. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/redis_connector/streams.py +0 -0
  56. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/redis_connector/validation.py +0 -0
  57. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/request_context.py +0 -0
  58. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/request_items.py +0 -0
  59. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scan_args.py +0 -0
  60. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scan_data_container.py +0 -0
  61. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scan_history.py +0 -0
  62. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scan_input_validator.py +0 -0
  63. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scan_items.py +0 -0
  64. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scan_manager.py +0 -0
  65. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scan_number_container.py +0 -0
  66. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scan_repeat.py +0 -0
  67. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scan_report.py +0 -0
  68. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scans.py +0 -0
  69. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/scibec_validator.py +0 -0
  70. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/script_executor.py +0 -0
  71. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/serialization.py +0 -0
  72. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/serialization_registry.py +0 -0
  73. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/service_config.py +0 -0
  74. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/signature_serializer.py +0 -0
  75. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/tests/__init__.py +0 -0
  76. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/tests/fixtures.py +0 -0
  77. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/tests/test_config.yaml +0 -0
  78. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/tests/test_service_config.yaml +0 -0
  79. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/tests/utils.py +0 -0
  80. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/user_macros.py +0 -0
  81. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/__init__.py +0 -0
  82. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/copier_jinja_filters.py +0 -0
  83. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/deprecation.py +0 -0
  84. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/import_utils.py +0 -0
  85. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/json_extended.py +0 -0
  86. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/plugin_manager/__init__.py +0 -0
  87. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/plugin_manager/_constants.py +0 -0
  88. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/plugin_manager/_util.py +0 -0
  89. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/plugin_manager/create/__init__.py +0 -0
  90. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/plugin_manager/create/device.py +0 -0
  91. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/plugin_manager/create/scan.py +0 -0
  92. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/plugin_manager/create/templates/scan.py.jinja +0 -0
  93. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/plugin_manager/main.py +0 -0
  94. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/proxy.py +0 -0
  95. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/pydantic_pretty_print.py +0 -0
  96. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/rpc_utils.py +0 -0
  97. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/scan_utils.py +0 -0
  98. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/threading_utils.py +0 -0
  99. {bec_lib-4.1.7 → bec_lib-4.2.0}/bec_lib/utils/user_acls_test.py +0 -0
  100. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/__init__.py +0 -0
  101. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/_additional_for_signature_serializer_test.py +0 -0
  102. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/conftest.py +0 -0
  103. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_acl_login.py +0 -0
  104. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_alarm_handler.py +0 -0
  105. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_atlas_models.py +0 -0
  106. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_beamline_states.py +0 -0
  107. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_bec_logger.py +0 -0
  108. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_bec_service.py +0 -0
  109. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_callback_handler.py +0 -0
  110. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_channel_monitor.py +0 -0
  111. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_client.py +0 -0
  112. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_config_helper.py +0 -0
  113. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_core_utils.py +0 -0
  114. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_dap_plugins.py +0 -0
  115. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_data_container.py +0 -0
  116. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_deprecation.py +0 -0
  117. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_device_hashing.py +0 -0
  118. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_device_manager.py +0 -0
  119. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_devices.py +0 -0
  120. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_file_utils.py +0 -0
  121. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_hli_connector_fakeredis.py +0 -0
  122. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_hli_redis_connector.py +0 -0
  123. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_import_utils.py +0 -0
  124. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_jinja_plugins.py +0 -0
  125. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_lmfit_serializer.py +0 -0
  126. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_managed_config_values.py +0 -0
  127. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_managed_redis_connection.py +0 -0
  128. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_messaging_hooks.py +0 -0
  129. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_messaging_service.py +0 -0
  130. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_metadata_schema.py +0 -0
  131. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_observer.py +0 -0
  132. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_pdf_writer.py +0 -0
  133. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_plugin_helper.py +0 -0
  134. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_plugin_manager.py +0 -0
  135. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_plugin_system.py +0 -0
  136. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_pydantic_pretty_print.py +0 -0
  137. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_queue_items.py +0 -0
  138. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_redis_connector_fakeredis.py +0 -0
  139. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_scan_context.py +0 -0
  140. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_scan_data.py +0 -0
  141. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_scan_history.py +0 -0
  142. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_scan_items.py +0 -0
  143. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_scan_manager.py +0 -0
  144. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_scan_object.py +0 -0
  145. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_scan_repeat.py +0 -0
  146. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_scan_report.py +0 -0
  147. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_scibec_validator.py +0 -0
  148. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_script_executor.py +0 -0
  149. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_serializer.py +0 -0
  150. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_signature_serializer.py +0 -0
  151. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_signature_serializer_with_future_import.py +0 -0
  152. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_stream_subs.py +0 -0
  153. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_user_macros.py +0 -0
  154. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_utils.py +0 -0
  155. {bec_lib-4.1.7 → bec_lib-4.2.0}/tests/test_yaml_loader.py +0 -0
  156. {bec_lib-4.1.7 → bec_lib-4.2.0}/util_scripts/init_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bec_lib
3
- Version: 4.1.7
3
+ Version: 4.2.0
4
4
  Summary: BEC library
5
5
  Project-URL: Bug Tracker, https://github.com/bec-project/bec/issues
6
6
  Project-URL: Homepage, https://github.com/bec-project/bec
@@ -361,6 +361,23 @@ class MessageEndpoints:
361
361
  message_op=MessageOp.KEY_VALUE,
362
362
  )
363
363
 
364
+ @staticmethod
365
+ def scan_device_info() -> EndpointInfo:
366
+ """
367
+ Endpoint for scan device runtime information. The device server publishes
368
+ messages.ScanDeviceInfoMessage snapshots for all scans to this shared stream.
369
+ Consumers should use scan_id to associate device information with a scan.
370
+
371
+ Returns:
372
+ EndpointInfo: Shared stream endpoint for scan device runtime information.
373
+ """
374
+ endpoint = f"{EndpointType.INFO.value}/scan_device_info"
375
+ return EndpointInfo(
376
+ endpoint=endpoint,
377
+ message_type=messages.ScanDeviceInfoMessage,
378
+ message_op=MessageOp.STREAM,
379
+ )
380
+
364
381
  @staticmethod
365
382
  def device_staged(device: str):
366
383
  """
@@ -24,9 +24,18 @@ from typing import (
24
24
  from uuid import uuid4
25
25
 
26
26
  import numpy as np
27
- from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
27
+ from pydantic import (
28
+ AliasChoices,
29
+ BaseModel,
30
+ ConfigDict,
31
+ Field,
32
+ ValidationError,
33
+ field_validator,
34
+ model_validator,
35
+ )
28
36
  from typing_extensions import deprecated
29
37
 
38
+ from bec_lib.endpoints import EndpointInfo, MessageOp
30
39
  from bec_lib.metadata_schema import get_metadata_schema_for_scan
31
40
 
32
41
 
@@ -606,6 +615,7 @@ DeviceInstructionAction = Literal[
606
615
  "baseline_reading",
607
616
  "close_scan",
608
617
  "publish_data_as_read",
618
+ "broadcast_scan_device_info",
609
619
  ]
610
620
 
611
621
 
@@ -629,6 +639,7 @@ class DeviceInstructionMessage(BECMessage):
629
639
  "baseline_reading",
630
640
  "close_scan",
631
641
  "publish_data_as_read",
642
+ "broadcast_scan_device_info",
632
643
  ]) : Device action, note rpc calls can run any method of the device. The function name needs to be specified in parameters['func']
633
644
  parameter (dict): Parameters required for the device action
634
645
  metadata (dict, optional): Metadata to describe the conditions of the device instruction
@@ -880,10 +891,91 @@ class DeviceInfoMessage(BECMessage):
880
891
  info: dict
881
892
 
882
893
 
894
+ class RedisSignalTransport(BaseModel):
895
+ """Redis subscription described by a resolved ``EndpointInfo``.
896
+
897
+ Consumers use their existing Redis connection. Endpoint strings are obtained
898
+ from ``MessageEndpoints`` helpers rather than constructed by callers.
899
+ """
900
+
901
+ transport: Literal["redis"] = "redis"
902
+ endpoint: str = Field(min_length=1, description="Resolved Redis endpoint.")
903
+ message_type: str = Field(min_length=1, description="Name of the BEC message class.")
904
+ message_op: MessageOp = Field(description="Supported Redis endpoint operations.")
905
+
906
+ model_config = ConfigDict(validate_assignment=True)
907
+
908
+ @classmethod
909
+ def from_endpoint_info(cls, endpoint_info: EndpointInfo) -> Self:
910
+ """Build a Redis transport descriptor from a message endpoint.
911
+
912
+ Args:
913
+ endpoint_info (EndpointInfo): Endpoint returned by a ``MessageEndpoints`` helper.
914
+
915
+ Returns:
916
+ Self: Transport descriptor containing the endpoint's subscription information.
917
+ """
918
+ return cls(
919
+ endpoint=endpoint_info.endpoint,
920
+ message_type=endpoint_info.message_type.__name__,
921
+ message_op=endpoint_info.message_op,
922
+ )
923
+
924
+
925
+ class ZMQSignalTransport(BaseModel):
926
+ """ZMQ image subscription described from the receiving client's perspective.
927
+
928
+ The address is a peer URI when connecting, or a local listening URI when
929
+ binding. The encoder identifies the complete wire format, including whether
930
+ a separate topic frame is present, rather than just JSON or CBOR encoding.
931
+ """
932
+
933
+ transport: Literal["zmq"] = "zmq"
934
+ address: str = Field(min_length=1, description="ZMQ URI to connect to or bind locally.")
935
+ socket_type: Literal["sub", "pull"] = Field(
936
+ default="sub", description="Client socket type: SUB for broadcast or PULL for a pipeline."
937
+ )
938
+ connection_mode: Literal["connect", "bind"] = Field(
939
+ default="connect", description="Whether the client connects to a peer or binds locally."
940
+ )
941
+ topic: str | None = Field(
942
+ default=None,
943
+ description="UTF-8 subscription prefix for SUB sockets. None means no named topic "
944
+ "and clients subscribe with an empty filter; an empty string also selects all messages. "
945
+ "PULL sockets must leave this unset. The encoder defines the topic framing.",
946
+ )
947
+ encoder: str = Field(
948
+ min_length=1, description="Identifier of the complete payload wire format."
949
+ )
950
+
951
+ model_config = ConfigDict(validate_assignment=True)
952
+
953
+ @model_validator(mode="before")
954
+ @classmethod
955
+ def validate_topic_socket_type(cls, value: Any) -> Any:
956
+ """Reject subscription topics for PULL sockets, including on assignment."""
957
+ if (
958
+ isinstance(value, Mapping)
959
+ and value.get("socket_type") == "pull"
960
+ and value.get("topic") is not None
961
+ ):
962
+ raise ValueError("Subscription topics are only supported for SUB sockets.")
963
+ return value
964
+
965
+
966
+ SignalTransport = Annotated[
967
+ RedisSignalTransport | ZMQSignalTransport, Field(discriminator="transport")
968
+ ]
969
+
970
+
883
971
  class SignalInfo(BaseModel):
884
972
  """
885
973
  Base class for signal information.
886
974
  This is used to store metadata about the signal.
975
+
976
+ Use ``correlation_group`` to group related signals. ``acquisition_group`` is
977
+ a deprecated input and attribute alias excluded from serialized messages.
978
+ If both names are supplied, ``correlation_group`` takes precedence.
887
979
  """
888
980
 
889
981
  data_type: Literal["raw", "processed"] = Field(
@@ -919,20 +1011,95 @@ class SignalInfo(BaseModel):
919
1011
  default=None,
920
1012
  description="Metadata for the signal, which can include additional information about the signal's properties.",
921
1013
  )
922
- acquisition_group: Literal["baseline", "monitored"] | str | None = Field(
1014
+ correlation_group: Literal["baseline", "monitored"] | str | None = Field(
923
1015
  default=None,
924
- description="""Specifies the acquisition group of the signal.
1016
+ validation_alias=AliasChoices("correlation_group", "acquisition_group"),
1017
+ description="""Specifies the correlation group of the signal.
925
1018
  It can be in sync with 'baseline' or 'monitored' groups mapping readoutPriority.
926
- Or mapped to a custom tag that allows grouping signals for acquisition and plotting.
927
- If None, the signal does not belong to any specific acquisition group.
1019
+ Or mapped to a custom tag that allows grouping signals for correlation and plotting.
1020
+ If None, the signal does not belong to any specific correlation group.
928
1021
  """,
929
1022
  )
1023
+ acquisition_group: Literal["baseline", "monitored"] | str | None = Field(
1024
+ default=None,
1025
+ deprecated=deprecated("acquisition_group is deprecated, use correlation_group instead"),
1026
+ description="Deprecated input and attribute alias for correlation_group.",
1027
+ repr=False,
1028
+ exclude=True,
1029
+ )
930
1030
  use_alias: bool = Field(
931
1031
  default=False, description="Whether the signal aliases one or more EPICS signals."
932
1032
  )
933
1033
 
1034
+ transports: list[SignalTransport] | None = Field(
1035
+ default=None,
1036
+ description="Available subscriptions for this signal, populated by the publisher. "
1037
+ "None means transport metadata is unavailable; consumers retain their existing routing. "
1038
+ "Clients choose a supported transport according to their own preferences.",
1039
+ )
1040
+
934
1041
  model_config = ConfigDict(validate_assignment=True)
935
1042
 
1043
+ @model_validator(mode="before")
1044
+ @classmethod
1045
+ def warn_deprecated_acquisition_group(cls, value: Any) -> Any:
1046
+ """Warn for legacy input; the canonical name takes precedence when both are supplied."""
1047
+ if (
1048
+ isinstance(value, Mapping)
1049
+ and "acquisition_group" in value
1050
+ and "correlation_group" not in value
1051
+ ):
1052
+ warnings.warn(
1053
+ "acquisition_group is deprecated, use correlation_group instead",
1054
+ DeprecationWarning,
1055
+ stacklevel=2,
1056
+ )
1057
+ return value
1058
+
1059
+ @model_validator(mode="after")
1060
+ def synchronize_acquisition_group(self) -> Self:
1061
+ """Keep the deprecated attribute synchronized with the canonical value."""
1062
+ self.__dict__["acquisition_group"] = self.correlation_group
1063
+ return self
1064
+
1065
+ def __setattr__(self, name: str, value: Any) -> None:
1066
+ if name == "acquisition_group":
1067
+ warnings.warn(
1068
+ "acquisition_group is deprecated, use correlation_group instead",
1069
+ DeprecationWarning,
1070
+ stacklevel=2,
1071
+ )
1072
+ name = "correlation_group"
1073
+ super().__setattr__(name, value)
1074
+
1075
+
1076
+ class DeviceRuntimeInfo(BaseModel):
1077
+ """Runtime information for a device, captured for a scan.
1078
+
1079
+ Args:
1080
+ signal_info (dict[str, SignalInfo]): BEC signal information keyed by dotted component
1081
+ name. Defaults to an empty dictionary for devices without BEC signals.
1082
+ disabled_signals (list[str]): Dotted component names of signals to ignore for this
1083
+ scan, relative to this device. Defaults to an empty list.
1084
+ """
1085
+
1086
+ signal_info: dict[str, SignalInfo] = Field(default_factory=dict)
1087
+ disabled_signals: list[str] = Field(default_factory=list)
1088
+
1089
+
1090
+ class ScanDeviceInfoMessage(BECMessage):
1091
+ """Snapshot of device runtime information for a scan.
1092
+
1093
+ Args:
1094
+ scan_id (str): Scan ID that the snapshot belongs to.
1095
+ devices (dict[str, DeviceRuntimeInfo]): Runtime information keyed by device name.
1096
+ metadata (dict, optional): Additional metadata.
1097
+ """
1098
+
1099
+ msg_type: ClassVar[str] = "scan_device_info_message"
1100
+ scan_id: str
1101
+ devices: dict[str, DeviceRuntimeInfo]
1102
+
936
1103
 
937
1104
  class DeviceMonitor2DMessage(BECMessage):
938
1105
  """Message type for sending device monitor updates from the device server.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bec_lib"
7
- version = "4.1.7"
7
+ version = "4.2.0"
8
8
  description = "BEC library"
9
9
  requires-python = ">=3.11"
10
10
  classifiers = [
@@ -1,10 +1,13 @@
1
1
  import getpass
2
+ import json
3
+ import warnings
2
4
 
3
5
  import numpy as np
4
6
  import pydantic
5
7
  import pytest
6
8
 
7
9
  from bec_lib import messages
10
+ from bec_lib.endpoints import MessageEndpoints, MessageOp
8
11
  from bec_lib.messaging_services import NotificationMessageObject
9
12
  from bec_lib.serialization import MsgpackSerialization
10
13
 
@@ -82,6 +85,8 @@ def test_signal_info_defaults_and_roundtrip():
82
85
  assert info.signals is None
83
86
  assert info.signal_metadata is None
84
87
  assert info.use_alias is False
88
+ assert info.correlation_group is None
89
+ assert info.transports is None
85
90
 
86
91
  configured = messages.SignalInfo(
87
92
  data_type="processed",
@@ -91,12 +96,242 @@ def test_signal_info_defaults_and_roundtrip():
91
96
  role="preview",
92
97
  signals=[("image", 1)],
93
98
  signal_metadata={"units": "counts"},
94
- acquisition_group="monitored",
99
+ correlation_group="monitored",
95
100
  use_alias=True,
96
101
  )
97
102
  assert messages.SignalInfo.model_validate(configured.model_dump()) == configured
98
103
 
99
104
 
105
+ @pytest.mark.parametrize(
106
+ "transports",
107
+ [
108
+ None,
109
+ [],
110
+ [messages.RedisSignalTransport.from_endpoint_info(MessageEndpoints.scan_status())],
111
+ [
112
+ messages.RedisSignalTransport.from_endpoint_info(
113
+ MessageEndpoints.device_preview("detector", "preview")
114
+ ).model_dump(mode="json"),
115
+ {
116
+ "transport": "zmq",
117
+ "address": "tcp://detector-host:5555",
118
+ "topic": "detector/preview",
119
+ "encoder": "bec-v1",
120
+ },
121
+ ],
122
+ ],
123
+ )
124
+ def test_signal_info_transports_roundtrip(transports):
125
+ info = messages.SignalInfo(transports=transports)
126
+ assert messages.SignalInfo.model_validate_json(info.model_dump_json()) == info
127
+ msg = messages.DeviceInfoMessage(device="detector", info=info.model_dump())
128
+ loaded = MsgpackSerialization.loads(MsgpackSerialization.dumps(msg))
129
+ assert messages.SignalInfo.model_validate(loaded.info) == info
130
+ if transports:
131
+ assert isinstance(info.transports[0], messages.RedisSignalTransport)
132
+ if len(transports) > 1:
133
+ assert isinstance(info.transports[1], messages.ZMQSignalTransport)
134
+
135
+
136
+ @pytest.mark.parametrize(
137
+ "transport",
138
+ [
139
+ {"transport": "unknown"},
140
+ {"transport": "redis"},
141
+ {"transport": "redis", "endpoint": ""},
142
+ {"transport": "redis", "endpoint": "preview", "message_op": MessageOp.STREAM},
143
+ {"transport": "redis", "endpoint": "preview", "message_type": "DevicePreviewMessage"},
144
+ {
145
+ "transport": "redis",
146
+ "endpoint": "preview",
147
+ "message_type": "DevicePreviewMessage",
148
+ "message_op": "invalid",
149
+ },
150
+ {"transport": "zmq", "address": "tcp://localhost:5555", "topic": "preview"},
151
+ {"transport": "zmq", "topic": "preview", "encoder": "bec-v1"},
152
+ {"transport": "zmq", "address": "", "topic": "preview", "encoder": "bec-v1"},
153
+ {"transport": "zmq", "address": "tcp://localhost:5555", "topic": "", "encoder": ""},
154
+ ],
155
+ )
156
+ def test_signal_info_transports_reject_invalid_descriptors(transport):
157
+ with pytest.raises(pydantic.ValidationError):
158
+ messages.SignalInfo(transports=[transport])
159
+ info = messages.SignalInfo()
160
+ with pytest.raises(pydantic.ValidationError):
161
+ info.transports = [transport]
162
+
163
+
164
+ @pytest.mark.parametrize(
165
+ "endpoint_info",
166
+ [
167
+ MessageEndpoints.device_preview("detector", "preview"),
168
+ MessageEndpoints.device_readback("samx"),
169
+ MessageEndpoints.scan_status(),
170
+ ],
171
+ )
172
+ def test_redis_signal_transport_from_endpoint_info(endpoint_info):
173
+ transport = messages.RedisSignalTransport.from_endpoint_info(endpoint_info)
174
+ assert transport.transport == "redis"
175
+ assert transport.endpoint == endpoint_info.endpoint
176
+ assert transport.message_type == endpoint_info.message_type.__name__
177
+ assert transport.message_op == endpoint_info.message_op
178
+ restored = messages.RedisSignalTransport.model_validate_json(transport.model_dump_json())
179
+ assert restored == transport
180
+ assert restored.message_op is endpoint_info.message_op
181
+ info = messages.SignalInfo(transports=[transport])
182
+ assert messages.SignalInfo.model_validate_json(info.model_dump_json()) == info
183
+ with pytest.raises(pydantic.ValidationError):
184
+ transport.endpoint = ""
185
+ with pytest.raises(pydantic.ValidationError):
186
+ transport.message_op = "invalid"
187
+
188
+
189
+ def test_zmq_signal_transport_assignment():
190
+ zmq = messages.ZMQSignalTransport(address="ipc:///tmp/bec-preview", topic="", encoder="bec-v1")
191
+ assert zmq.transport == "zmq"
192
+ with pytest.raises(pydantic.ValidationError):
193
+ zmq.encoder = ""
194
+
195
+
196
+ def test_zmq_signal_transport_defaults():
197
+ transport = messages.ZMQSignalTransport(address="tcp://localhost:5555", encoder="bec-v1")
198
+ assert transport.socket_type == "sub"
199
+ assert transport.connection_mode == "connect"
200
+ assert transport.topic is None
201
+
202
+
203
+ @pytest.mark.parametrize("socket_type", ["sub", "pull"])
204
+ @pytest.mark.parametrize("connection_mode", ["connect", "bind"])
205
+ def test_zmq_signal_transport_modes_roundtrip(socket_type, connection_mode):
206
+ transport = messages.ZMQSignalTransport(
207
+ address="tcp://localhost:5555",
208
+ socket_type=socket_type,
209
+ connection_mode=connection_mode,
210
+ encoder="dectris-stream-v2" if socket_type == "pull" else "array-1.0",
211
+ )
212
+ info = messages.SignalInfo(transports=[transport])
213
+ assert messages.SignalInfo.model_validate_json(info.model_dump_json()) == info
214
+ msg = messages.DeviceInfoMessage(device="detector", info=info.model_dump())
215
+ loaded = MsgpackSerialization.loads(MsgpackSerialization.dumps(msg))
216
+ assert messages.SignalInfo.model_validate(loaded.info) == info
217
+
218
+
219
+ @pytest.mark.parametrize("topic", [None, "", "camera/color"])
220
+ def test_zmq_signal_transport_sub_topics(topic):
221
+ transport = messages.ZMQSignalTransport(
222
+ address="tcp://localhost:5555", topic=topic, encoder="camera-stream-v1"
223
+ )
224
+ assert transport.topic == topic
225
+ assert messages.ZMQSignalTransport.model_validate_json(transport.model_dump_json()) == transport
226
+
227
+
228
+ @pytest.mark.parametrize(
229
+ "invalid_fields",
230
+ [
231
+ {"socket_type": "pub"},
232
+ {"connection_mode": "listen"},
233
+ {"socket_type": "pull", "topic": "camera/color"},
234
+ {"socket_type": "pull", "topic": ""},
235
+ ],
236
+ )
237
+ def test_zmq_signal_transport_rejects_invalid_modes(invalid_fields):
238
+ with pytest.raises(pydantic.ValidationError):
239
+ messages.SignalInfo(
240
+ transports=[
241
+ {
242
+ "transport": "zmq",
243
+ "address": "tcp://localhost:5555",
244
+ "encoder": "bec-v1",
245
+ **invalid_fields,
246
+ }
247
+ ]
248
+ )
249
+
250
+
251
+ def test_zmq_signal_transport_topic_assignment():
252
+ transport = messages.ZMQSignalTransport(
253
+ address="tcp://localhost:5555", topic="preview", encoder="bec-v1"
254
+ )
255
+ with pytest.raises(pydantic.ValidationError):
256
+ transport.socket_type = "pull"
257
+ assert transport.socket_type == "sub"
258
+ transport.topic = None
259
+ transport.socket_type = "pull"
260
+ with pytest.raises(pydantic.ValidationError):
261
+ transport.topic = "preview"
262
+ assert transport.topic is None
263
+ transport.connection_mode = "bind"
264
+ with pytest.raises(pydantic.ValidationError):
265
+ transport.connection_mode = "listen"
266
+ assert transport.connection_mode == "bind"
267
+
268
+
269
+ @pytest.mark.parametrize("group", [None, "baseline", "monitored", "fly-scan"])
270
+ def test_signal_info_correlation_group_legacy_compatibility(group):
271
+ info = messages.SignalInfo(correlation_group=group)
272
+ assert info.correlation_group == group
273
+ with pytest.warns(DeprecationWarning, match="use correlation_group instead"):
274
+ legacy = messages.SignalInfo(acquisition_group=group)
275
+ assert legacy == info
276
+ with pytest.warns(DeprecationWarning, match="use correlation_group instead"):
277
+ assert info.acquisition_group == group
278
+ payload = info.model_dump()
279
+ assert payload["correlation_group"] == group
280
+ assert "acquisition_group" not in payload
281
+ assert legacy.model_dump() == payload
282
+ assert messages.SignalInfo.model_validate(payload) == info
283
+
284
+
285
+ def test_signal_info_correlation_group_assignment():
286
+ info = messages.SignalInfo(correlation_group="baseline")
287
+ with pytest.warns(DeprecationWarning, match="use correlation_group instead"):
288
+ info.acquisition_group = "monitored"
289
+ assert info.correlation_group == "monitored"
290
+ info.correlation_group = "fly-scan"
291
+ with pytest.warns(DeprecationWarning, match="use correlation_group instead"):
292
+ assert info.acquisition_group == "fly-scan"
293
+ with pytest.raises(pydantic.ValidationError):
294
+ info.correlation_group = 42
295
+ with pytest.warns(DeprecationWarning, match="use correlation_group instead"):
296
+ with pytest.raises(pydantic.ValidationError):
297
+ info.acquisition_group = 42
298
+ assert info.correlation_group == "fly-scan"
299
+ with pytest.warns(DeprecationWarning, match="use correlation_group instead"):
300
+ info.acquisition_group = None
301
+ assert info.correlation_group is None
302
+
303
+
304
+ def test_signal_info_correlation_group_takes_precedence():
305
+ info = messages.SignalInfo(correlation_group=None, acquisition_group="legacy")
306
+ assert info.correlation_group is None
307
+ assert "acquisition_group" not in info.model_dump()
308
+
309
+
310
+ def test_signal_info_acquisition_group_schema_is_deprecated():
311
+ properties = messages.SignalInfo.model_json_schema(mode="validation")["properties"]
312
+ assert properties["acquisition_group"]["deprecated"] is True
313
+ assert not properties["correlation_group"].get("deprecated", False)
314
+ serialized = messages.SignalInfo.model_json_schema(mode="serialization")["properties"]
315
+ assert "acquisition_group" not in serialized
316
+
317
+
318
+ def test_signal_info_serialization_excludes_deprecated_alias_without_warnings():
319
+ info = messages.SignalInfo(correlation_group="monitored")
320
+ msg = messages.ScanDeviceInfoMessage(
321
+ scan_id="scan-1",
322
+ devices={"detector": messages.DeviceRuntimeInfo(signal_info={"preview": info})},
323
+ )
324
+ with warnings.catch_warnings(record=True) as caught:
325
+ warnings.simplefilter("always")
326
+ payload = info.model_dump()
327
+ assert json.loads(info.model_dump_json()) == payload
328
+ assert "acquisition_group" not in payload
329
+ nested = msg.model_dump()["devices"]["detector"]["signal_info"]["preview"]
330
+ assert nested == payload
331
+ assert MsgpackSerialization.loads(MsgpackSerialization.dumps(msg)) == msg
332
+ assert not caught
333
+
334
+
100
335
  def test_signal_info_rejects_invalid_dimensions():
101
336
  with pytest.raises(pydantic.ValidationError):
102
337
  messages.SignalInfo(ndim=3)
@@ -112,6 +347,65 @@ def test_signal_info_rejects_invalid_dimension_assignment():
112
347
  assert info.ndim == 2
113
348
 
114
349
 
350
+ def test_scan_device_info_message_roundtrip():
351
+ msg = messages.ScanDeviceInfoMessage(
352
+ scan_id="scan-1",
353
+ devices={
354
+ "eiger": messages.DeviceRuntimeInfo(
355
+ signal_info={
356
+ "preview": messages.SignalInfo(
357
+ data_type="processed",
358
+ saved=False,
359
+ ndim=2,
360
+ scope="continuous",
361
+ role="preview",
362
+ rpc_access=True,
363
+ signals=[("image", 1)],
364
+ signal_metadata={"units": "counts"},
365
+ correlation_group="monitored",
366
+ use_alias=True,
367
+ )
368
+ },
369
+ disabled_signals=["raw_image", "sub.diagnostic"],
370
+ ),
371
+ "samx": messages.DeviceRuntimeInfo(),
372
+ },
373
+ metadata={"RID": "rid-1"},
374
+ )
375
+
376
+ res = MsgpackSerialization.dumps(msg)
377
+ res_loaded = MsgpackSerialization.loads(res)
378
+
379
+ assert res_loaded == msg
380
+ assert res_loaded.devices["eiger"].signal_info["preview"].ndim == 2
381
+ assert res_loaded.devices["eiger"].signal_info["preview"].role == "preview"
382
+ assert res_loaded.devices["eiger"].disabled_signals == ["raw_image", "sub.diagnostic"]
383
+
384
+
385
+ def test_device_runtime_info_defaults_are_independent():
386
+ detector = messages.DeviceRuntimeInfo()
387
+ motor = messages.DeviceRuntimeInfo()
388
+ detector.signal_info["preview"] = messages.SignalInfo(role="preview")
389
+ detector.disabled_signals.append("raw_image")
390
+ assert motor.signal_info == {}
391
+ assert motor.disabled_signals == []
392
+
393
+
394
+ def test_scan_device_info_validates_nested_signal_info():
395
+ with pytest.raises(pydantic.ValidationError):
396
+ messages.ScanDeviceInfoMessage(
397
+ scan_id="scan-1", devices={"eiger": {"signal_info": {"preview": {"ndim": 3}}}}
398
+ )
399
+
400
+
401
+ def test_scan_device_info_endpoint_contract():
402
+ endpoint = MessageEndpoints.scan_device_info()
403
+
404
+ assert endpoint.endpoint == "info/scan_device_info"
405
+ assert endpoint.message_type is messages.ScanDeviceInfoMessage
406
+ assert endpoint.message_op == MessageOp.STREAM
407
+
408
+
115
409
  def test_bundled_message():
116
410
  sub_msg = messages.DeviceMessage(signals={"samx": {"value": 5.2}}, metadata={"RID": "1234"})
117
411
  msg = messages.BundleMessage()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes