MTPPy2.0 0.1.5__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 (214) hide show
  1. examples/__init__.py +0 -0
  2. examples/minimal+pid.py +82 -0
  3. examples/minimal.py +32 -0
  4. examples/minimal_recipe.py +117 -0
  5. examples/pea_alarm_base.py +47 -0
  6. examples/pea_minimal.py +258 -0
  7. mtppy/__init__.py +1 -0
  8. mtppy/part1/__init__.py +4 -0
  9. mtppy/part1/empty_folder_test.py +7 -0
  10. mtppy/part3/__init__.py +4 -0
  11. mtppy/part3/data_assembly_set/__init__.py +4 -0
  12. mtppy/part3/data_assembly_set/active_elements/__init__.py +4 -0
  13. mtppy/part3/data_assembly_set/active_elements/active_element.py +22 -0
  14. mtppy/part3/data_assembly_set/active_elements/drives/__init__.py +4 -0
  15. mtppy/part3/data_assembly_set/active_elements/drives/ana_drv.py +446 -0
  16. mtppy/part3/data_assembly_set/active_elements/drives/ana_drv_test.py +567 -0
  17. mtppy/part3/data_assembly_set/active_elements/drives/bin_drv.py +365 -0
  18. mtppy/part3/data_assembly_set/active_elements/drives/bin_drv_test.py +503 -0
  19. mtppy/part3/data_assembly_set/active_elements/drives/mon_ana_drv.py +469 -0
  20. mtppy/part3/data_assembly_set/active_elements/drives/mon_ana_drv_test.py +418 -0
  21. mtppy/part3/data_assembly_set/active_elements/drives/mon_bin_drv.py +295 -0
  22. mtppy/part3/data_assembly_set/active_elements/drives/mon_bin_drv_test.py +265 -0
  23. mtppy/part3/data_assembly_set/active_elements/pid/__init__.py +4 -0
  24. mtppy/part3/data_assembly_set/active_elements/pid/pid_ctrl.py +333 -0
  25. mtppy/part3/data_assembly_set/active_elements/pid/pid_ctrl_test.py +156 -0
  26. mtppy/part3/data_assembly_set/active_elements/valves/__init__.py +4 -0
  27. mtppy/part3/data_assembly_set/active_elements/valves/ana_vlv.py +402 -0
  28. mtppy/part3/data_assembly_set/active_elements/valves/ana_vlv_test.py +732 -0
  29. mtppy/part3/data_assembly_set/active_elements/valves/bin_vlv.py +279 -0
  30. mtppy/part3/data_assembly_set/active_elements/valves/bin_vlv_test.py +585 -0
  31. mtppy/part3/data_assembly_set/active_elements/valves/mon_ana_vlv.py +402 -0
  32. mtppy/part3/data_assembly_set/active_elements/valves/mon_ana_vlv_test.py +270 -0
  33. mtppy/part3/data_assembly_set/active_elements/valves/mon_bin_vlv.py +286 -0
  34. mtppy/part3/data_assembly_set/active_elements/valves/mon_bin_vlv_test.py +224 -0
  35. mtppy/part3/data_assembly_set/base_functions/__init__.py +4 -0
  36. mtppy/part3/data_assembly_set/base_functions/operation_mode.py +252 -0
  37. mtppy/part3/data_assembly_set/base_functions/operation_mode_test.py +260 -0
  38. mtppy/part3/data_assembly_set/base_functions/oslevel.py +35 -0
  39. mtppy/part3/data_assembly_set/base_functions/source_mode.py +189 -0
  40. mtppy/part3/data_assembly_set/base_functions/source_mode_test.py +112 -0
  41. mtppy/part3/data_assembly_set/base_functions/wqc.py +75 -0
  42. mtppy/part3/data_assembly_set/base_functions/wqc_test.py +86 -0
  43. mtppy/part3/data_assembly_set/data_assembly.py +65 -0
  44. mtppy/part3/data_assembly_set/diagnostic_elements/__init__.py +4 -0
  45. mtppy/part3/data_assembly_set/diagnostic_elements/diagnostic_element.py +19 -0
  46. mtppy/part3/data_assembly_set/indicator_elements/__init__.py +4 -0
  47. mtppy/part3/data_assembly_set/indicator_elements/indicator_element.py +22 -0
  48. mtppy/part3/data_assembly_set/indicator_elements/views/__init__.py +4 -0
  49. mtppy/part3/data_assembly_set/indicator_elements/views/ana_view.py +68 -0
  50. mtppy/part3/data_assembly_set/indicator_elements/views/bin_view.py +73 -0
  51. mtppy/part3/data_assembly_set/indicator_elements/views/dint_view.py +76 -0
  52. mtppy/part3/data_assembly_set/indicator_elements/views/string_view.py +55 -0
  53. mtppy/part3/data_assembly_set/operation_elements/__init__.py +4 -0
  54. mtppy/part3/data_assembly_set/operation_elements/operation_element.py +19 -0
  55. mtppy/part3/data_assembly_set/pea_elements/__init__.py +4 -0
  56. mtppy/part3/data_assembly_set/pea_elements/pea_element.py +28 -0
  57. mtppy/part3/data_assembly_set/pea_elements/pea_information_label/__init__.py +4 -0
  58. mtppy/part3/data_assembly_set/pea_elements/pea_information_label/pea_information_label.py +231 -0
  59. mtppy/part3/data_assembly_set/pea_elements/web_server_url_info/__init__.py +4 -0
  60. mtppy/part3/data_assembly_set/pea_elements/web_server_url_info/web_server_url_info.py +42 -0
  61. mtppy/part3/definitions/__init__.py +4 -0
  62. mtppy/part3/definitions/mtp_access.py +15 -0
  63. mtppy/part4/__init__.py +4 -0
  64. mtppy/part4/automation_services/__init__.py +4 -0
  65. mtppy/part4/automation_services/base_functions/__init__.py +4 -0
  66. mtppy/part4/automation_services/base_functions/service_operation_mode.py +368 -0
  67. mtppy/part4/automation_services/base_functions/service_operation_mode_test.py +242 -0
  68. mtppy/part4/automation_services/base_functions/service_operator_interaction.py +62 -0
  69. mtppy/part4/automation_services/base_functions/service_source_mode.py +138 -0
  70. mtppy/part4/automation_services/base_functions/service_source_mode_test.py +179 -0
  71. mtppy/part4/automation_services/helpers/__init__.py +4 -0
  72. mtppy/part4/automation_services/helpers/command_en_control.py +330 -0
  73. mtppy/part4/automation_services/helpers/command_en_control_test.py +302 -0
  74. mtppy/part4/automation_services/helpers/state_machine.py +167 -0
  75. mtppy/part4/automation_services/helpers/state_machine_test.py +294 -0
  76. mtppy/part4/automation_services/helpers/thread_control.py +39 -0
  77. mtppy/part4/automation_services/helpers/thread_control_test.py +80 -0
  78. mtppy/part4/automation_services/procedure.py +111 -0
  79. mtppy/part4/automation_services/procedure_test.py +185 -0
  80. mtppy/part4/automation_services/service.py +463 -0
  81. mtppy/part4/automation_services/service_elements/__init__.py +4 -0
  82. mtppy/part4/automation_services/service_elements/parameter_elements/__init__.py +4 -0
  83. mtppy/part4/automation_services/service_elements/parameter_elements/ana_serv_param.py +108 -0
  84. mtppy/part4/automation_services/service_elements/parameter_elements/ana_serv_param_test.py +59 -0
  85. mtppy/part4/automation_services/service_elements/parameter_elements/bin_serv_param.py +70 -0
  86. mtppy/part4/automation_services/service_elements/parameter_elements/bin_serv_param_test.py +39 -0
  87. mtppy/part4/automation_services/service_elements/parameter_elements/common_serv_param.py +111 -0
  88. mtppy/part4/automation_services/service_elements/parameter_elements/dint_serv_param.py +77 -0
  89. mtppy/part4/automation_services/service_elements/parameter_elements/dint_serv_param_test.py +58 -0
  90. mtppy/part4/automation_services/service_elements/parameter_elements/parameter_element.py +175 -0
  91. mtppy/part4/automation_services/service_elements/parameter_elements/parameter_element_test.py +246 -0
  92. mtppy/part4/automation_services/service_elements/parameter_elements/string_serv_param.py +48 -0
  93. mtppy/part4/automation_services/service_elements/parameter_elements/string_serv_param_test.py +37 -0
  94. mtppy/part4/automation_services/service_elements/procedure_health_view.py +36 -0
  95. mtppy/part4/automation_services/service_elements/service_control.py +473 -0
  96. mtppy/part4/automation_services/service_elements/service_control_test.py +150 -0
  97. mtppy/part4/automation_services/service_elements/service_element.py +20 -0
  98. mtppy/part4/automation_services/service_test.py +407 -0
  99. mtppy/part4/definitions/__init__.py +4 -0
  100. mtppy/part4/definitions/command_codes.py +58 -0
  101. mtppy/part4/definitions/command_codes_test.py +44 -0
  102. mtppy/part4/definitions/state_codes.py +65 -0
  103. mtppy/part4/definitions/state_codes_test.py +44 -0
  104. mtppy/part4/process_values/__init__.py +4 -0
  105. mtppy/part4/process_values/base_functions/__init__.py +4 -0
  106. mtppy/part4/process_values/base_functions/vqc.py +48 -0
  107. mtppy/part4/process_values/input_elements/__init__.py +4 -0
  108. mtppy/part4/process_values/input_elements/ana_process_value_in.py +160 -0
  109. mtppy/part4/process_values/input_elements/bin_process_value_in.py +83 -0
  110. mtppy/part4/process_values/input_elements/dint_process_value_in.py +159 -0
  111. mtppy/part4/process_values/input_elements/input_element.py +40 -0
  112. mtppy/part4/process_values/input_elements/string_process_value_in.py +45 -0
  113. mtppy/part5/__init__.py +4 -0
  114. mtppy/part5/communication_object.py +99 -0
  115. mtppy/part5/communication_object_test.py +59 -0
  116. mtppy/part5/definitions/__init__.py +4 -0
  117. mtppy/part5/definitions/iec61131_data_types.py +31 -0
  118. mtppy/part5/definitions/iec61131_data_types_test.py +82 -0
  119. mtppy/part5/definitions/opcua_data_types.py +36 -0
  120. mtppy/part5/definitions/opcua_data_types_test.py +90 -0
  121. mtppy/part5/definitions/w3c_xml_data_types.py +177 -0
  122. mtppy/part5/definitions/w3c_xml_data_types_test.py +143 -0
  123. mtppy/part5/server_assembly_set/__init__.py +5 -0
  124. mtppy/part6/__init__.py +4 -0
  125. mtppy/part6/alarm_set/__init__.py +4 -0
  126. mtppy/part6/alarm_set/alarm.py +132 -0
  127. mtppy/part6/alarm_set/alarm_test.py +265 -0
  128. mtppy/pea/__init__.py +19 -0
  129. mtppy/pea/data_assembly_factory.py +124 -0
  130. mtppy/pea/data_assembly_factory_test.py +229 -0
  131. mtppy/pea/mtp_generator/__init__.py +4 -0
  132. mtppy/pea/mtp_generator/mtp_generator.py +1183 -0
  133. mtppy/pea/mtp_generator/mtp_generator_test.py +518 -0
  134. mtppy/pea/pea.py +139 -0
  135. mtppy/pea/pea_opcua_server/__init__.py +4 -0
  136. mtppy/pea/pea_opcua_server/marshalling.py +60 -0
  137. mtppy/pea/pea_opcua_server/opcua_options_provider/__init__.py +4 -0
  138. mtppy/pea/pea_opcua_server/opcua_options_provider/instances/basic_opcua_options_provider.py +50 -0
  139. mtppy/pea/pea_opcua_server/opcua_options_provider/opcua_options_provider.py +103 -0
  140. mtppy/pea/pea_opcua_server/pea_opcua_server.py +492 -0
  141. mtppy/pea/pea_opcua_server/subscription_list.py +55 -0
  142. mtppy/pea/pea_test.py +59 -0
  143. mtppy/pea/rmtp_pea.py +155 -0
  144. mtppy/pea/rmtp_pea_test.py +27 -0
  145. mtppy/pea/service_factory.py +178 -0
  146. mtppy/pea/service_factory_test.py +156 -0
  147. mtppy/pea/user_logic/__init__.py +4 -0
  148. mtppy/pea/user_logic/user_logic_pea.py +72 -0
  149. mtppy/pea/user_logic/user_logic_registry.py +106 -0
  150. mtppy/pea/user_logic/user_logic_registry_test.py +167 -0
  151. mtppy/pea/user_logic/user_logic_service.py +228 -0
  152. mtppy/pea/user_logic/user_logic_service_test.py +289 -0
  153. mtppy/utils/__init__.py +4 -0
  154. mtppy/utils/data_assembly_dict.py +61 -0
  155. mtppy/utils/data_assembly_dict_test.py +107 -0
  156. mtppy/utils/data_item.py +446 -0
  157. mtppy/utils/data_item_com_test.py +82 -0
  158. mtppy/utils/data_item_test.py +325 -0
  159. mtppy/utils/logging.py +23 -0
  160. mtppy/utils/logging_test.py +26 -0
  161. mtppy/utils/mtp_file_handling/__init__.py +4 -0
  162. mtppy/utils/mtp_file_handling/extract_manifest_from_mtp_file.py +22 -0
  163. mtppy/utils/mtp_file_handling/extract_mtp_file.py +22 -0
  164. mtppy/utils/mtp_file_handling/is_valid_mtp.py +22 -0
  165. mtppy/utils/mtp_file_handling/is_valid_path_for_manifest.py +15 -0
  166. mtppy/utils/mtp_file_handling/is_valid_path_to_manifest.py +17 -0
  167. mtppy/utils/mtp_file_handling/is_valid_path_to_mtp.py +17 -0
  168. mtppy/utils/mtp_file_handling/load_manifest_file.py +24 -0
  169. mtppy/utils/mtp_file_handling/mtp_reader/__init__.py +4 -0
  170. mtppy/utils/mtp_file_handling/mtp_reader/mtp_reader.py +103 -0
  171. mtppy/utils/mtp_file_handling/mtp_reader/mtp_reader_test.py +96 -0
  172. mtppy/utils/mtp_file_handling/mtp_reader/mtp_xml.py +226 -0
  173. mtppy/utils/mtp_file_handling/mtp_reader/mtp_xml_test.py +108 -0
  174. mtppy/utils/mtp_file_handling/mtp_writer/__init__.py +4 -0
  175. mtppy/utils/mtp_file_handling/mtp_writer/mtp_writer.py +77 -0
  176. mtppy/utils/mtp_file_handling/mtp_writer/ressources/get_content_types.py +10 -0
  177. mtppy/utils/mtp_file_handling/mtp_writer/ressources/get_rels.py +11 -0
  178. mtppy/utils/mtp_file_handling/mtp_writer/test_mtp_writer.py +25 -0
  179. mtppy/utils/mtp_file_handling/write_manifest_file.py +29 -0
  180. mtppy/utils/mtp_file_handling/write_manifest_file_test.py +22 -0
  181. mtppy/utils/mtp_file_handling/write_mtp_file.py +43 -0
  182. mtppy/utils/mtp_file_handling/write_mtp_file_test.py +22 -0
  183. mtppy/utils/testing.py +31 -0
  184. mtppy2_0-0.1.5.dist-info/METADATA +75 -0
  185. mtppy2_0-0.1.5.dist-info/RECORD +214 -0
  186. mtppy2_0-0.1.5.dist-info/WHEEL +5 -0
  187. mtppy2_0-0.1.5.dist-info/entry_points.txt +3 -0
  188. mtppy2_0-0.1.5.dist-info/licenses/LICENSE +21 -0
  189. mtppy2_0-0.1.5.dist-info/top_level.txt +4 -0
  190. tools/__init__.py +0 -0
  191. tools/create_bugreport.py +150 -0
  192. tools/inspect_mtp.py +110 -0
  193. utils/__init__.py +1 -0
  194. utils/file_handling/__init__.py +4 -0
  195. utils/file_handling/cleanup_folder.py +9 -0
  196. utils/file_handling/get_utc_foldername_with_ms.py +15 -0
  197. utils/file_handling/get_work_dir.py +9 -0
  198. utils/file_handling/is_valid_path_for_json.py +11 -0
  199. utils/file_handling/is_valid_path_to_json.py +17 -0
  200. utils/file_handling/load_json_file.py +18 -0
  201. utils/file_handling/read_file_from_file_archive.py +43 -0
  202. utils/file_handling/read_file_string.py +10 -0
  203. utils/file_handling/validate_json_data.py +28 -0
  204. utils/file_handling/write_json_file.py +23 -0
  205. utils/get_uuid.py +18 -0
  206. utils/get_uuid_test.py +17 -0
  207. utils/logging.py +100 -0
  208. utils/logging_test.py +67 -0
  209. utils/opcua/check_node.py +79 -0
  210. utils/resolve_unit/resolve_unit.py +11 -0
  211. utils/resolve_unit/unit_lib.py +2101 -0
  212. utils/resolve_unit/unit_lib_test.py +36 -0
  213. utils/type_conversion.py +236 -0
  214. utils/type_conversion_test.py +114 -0
@@ -0,0 +1,446 @@
1
+ """DataItem and DataItemCollection, core API for data management and notification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __copyright__ = (
6
+ "Copyright (c) 2026 Dresden University of Technology, Process-to-Order Group"
7
+ )
8
+ __license__ = "MIT"
9
+
10
+
11
+ import copy
12
+ from collections.abc import (
13
+ Callable,
14
+ Iterator,
15
+ KeysView,
16
+ MutableMapping,
17
+ )
18
+ from typing import TYPE_CHECKING
19
+
20
+ if TYPE_CHECKING:
21
+ import utils.type_conversion as tc
22
+ from mtppy.part5.communication_object import CommunicationObject
23
+
24
+ from mtppy.part3.definitions.mtp_access import MTPAccess
25
+ from mtppy.part5.definitions.w3c_xml_data_types import W3cXmlDataTypes
26
+ from mtppy.utils.logging import logger
27
+
28
+
29
+ class DataItem:
30
+ """Represent a scalar typed value hosted by a data-item collection.
31
+
32
+ A DataItem validates and stores its value according to a W3C XML data
33
+ type. It can notify an internal callback and any number of external
34
+ communication observers when its value is written.
35
+
36
+ The class is hosted by a
37
+ [DataItemCollection][mtppy.utils.data_item.DataItemCollection],
38
+ the base class for
39
+ [DataAssembly][mtppy.part3.data_assembly_set.data_assembly.DataAssembly].
40
+
41
+ A DataItem is associated with an Attribute of a DataAssembly definition
42
+ within MTP 2.0.
43
+
44
+ Attributes:
45
+ name: DataItem name.
46
+ w3c_xml_data_type: DataItem value type.
47
+ access: DataItem access mode.
48
+ init_value: Initial, type-corrected value.
49
+ description: Human-readable description.
50
+ ext_wrt_cb: Callback invoked for external writes.
51
+ int_wrt_cb: Callback invoked for internal writes.
52
+ communication_objects: External communication observers notified on
53
+ value writes.
54
+ comm_obj: Backwards-compatible alias for the first communication
55
+ observer.
56
+ _value: Current type-corrected value.
57
+ _is_notifying: Whether an internal notification is in progress.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ name: str,
63
+ w3c_xml_data_type: W3cXmlDataTypes,
64
+ access: MTPAccess,
65
+ init_value: tc.ANY_TYPE | None = 0,
66
+ description: str = "",
67
+ external_write_cb: Callable[[tc.ANY_TYPE], None] | None = None,
68
+ internal_write_cb: Callable[[tc.ANY_TYPE], None] | None = None,
69
+ ) -> None:
70
+ """Initialize a DataItem.
71
+
72
+ Args:
73
+ name: Name of the data item.
74
+ w3c_xml_data_type: W3C XML type used to convert assigned values.
75
+ access: Access mode for the data item.
76
+ init_value: Initial value. ``None`` is treated as ``0``.
77
+ description: Human-readable description.
78
+ external_write_cb: Callback for external writes received through
79
+ the data item.
80
+ internal_write_cb: Callback for internal writes to the data item.
81
+
82
+ Raises:
83
+ TypeError: If ``w3c_xml_data_type`` or ``access`` is not the
84
+ expected enumeration type.
85
+ """
86
+ self.name: str = name
87
+ # Type checking
88
+ if not isinstance(
89
+ w3c_xml_data_type,
90
+ W3cXmlDataTypes,
91
+ ): # pyright: ignore[reportUnnecessaryIsInstance]
92
+ exc_msg = f"data type {w3c_xml_data_type} not supported"
93
+ raise TypeError(exc_msg)
94
+ self.w3c_xml_data_type: W3cXmlDataTypes = w3c_xml_data_type
95
+ if not isinstance(
96
+ access,
97
+ MTPAccess,
98
+ ): # pyright: ignore[reportUnnecessaryIsInstance]
99
+ exc_msg = f"access type {access} not supported"
100
+ raise TypeError(exc_msg)
101
+ self.access: MTPAccess = access
102
+ self._is_notifying: bool = False
103
+
104
+ if init_value is None:
105
+ init_value = 0
106
+
107
+ self.description: str = description
108
+ type_casted_value = self._correct_type(init_value)
109
+ self.init_value: tc.ANY_TYPE = type_casted_value
110
+ self._value: tc.ANY_TYPE = type_casted_value
111
+ self.communication_objects: list[CommunicationObject] = []
112
+ self.ext_wrt_cb: Callable[[tc.ANY_TYPE], None] | None = external_write_cb
113
+ self.int_wrt_cb: Callable[[tc.ANY_TYPE], None] | None = internal_write_cb
114
+
115
+ def get_value(self) -> tc.ANY_TYPE:
116
+ """Return the current value of the DataItem.
117
+
118
+ Returns:
119
+ The current value, already converted to the DataItem's
120
+ pythonic ``w3c_xml_data_type``
121
+ """
122
+ return self._value
123
+
124
+ def get_value_str(self) -> str:
125
+ """Return a human-readable string representation of the DataItem.
126
+
127
+ Returns:
128
+ A string of the form ``"<name>=<value>"``, e.g. ``"WQC=128"``.
129
+ """
130
+ return f"{self.name}={self._value}"
131
+
132
+ def _notify_local_subscribers(self, value: tc.ANY_TYPE) -> None:
133
+ """Notify the internal subscriber and update the stored value.
134
+
135
+ Recursive writes made by the internal callback update the value
136
+ directly while the outer notification remains active.
137
+
138
+ Args:
139
+ value: Type-corrected value to publish internally.
140
+ """
141
+ if self._is_notifying: # Our callbacks did all the checks, now finally write
142
+ self._value = value
143
+ return
144
+ self._is_notifying = True
145
+ try:
146
+ if self.int_wrt_cb is not None:
147
+ logger.mtp(f"{self.name}:={value} => {self.int_wrt_cb.__name__}")
148
+ self.int_wrt_cb(value)
149
+ else:
150
+ self._value = value
151
+ finally:
152
+ self._is_notifying = False
153
+
154
+ def _notify_external_subscribers(self) -> None:
155
+ """Notify each attached external communication observer."""
156
+ if not self.communication_objects:
157
+ logger.opcua(f"{self.name}:={self._value} => no communication object")
158
+ return
159
+
160
+ for communication_object in self.communication_objects:
161
+ communication_object.write_value_callback(self._value)
162
+ logger.opcua(f"{self.name}:={self._value} => notified")
163
+
164
+ @property
165
+ def comm_obj(self) -> CommunicationObject | None:
166
+ """Return the first communication object, if one is attached.
167
+
168
+ Returns:
169
+ The first attached observer, or ``None`` when no observer exists.
170
+ """
171
+ return self.communication_objects[0] if self.communication_objects else None
172
+
173
+ @comm_obj.setter
174
+ def comm_obj(self, communication_object: CommunicationObject | None) -> None:
175
+ """Replace all observers with one communication object.
176
+
177
+ Args:
178
+ communication_object: Observer to retain, or ``None`` to remove
179
+ all observers.
180
+ """
181
+ self.communication_objects = (
182
+ [] if communication_object is None else [communication_object]
183
+ )
184
+
185
+ def set_value(self, value: tc.ANY_TYPE) -> None:
186
+ """Set the value and notify internal and external subscribers.
187
+
188
+ Args:
189
+ value: Value to convert, store, and publish.
190
+ """
191
+ corrected_value = self._correct_type(value)
192
+ if self.get_value() != corrected_value:
193
+ self._notify_local_subscribers(corrected_value)
194
+ # do we really always want to try to notify external subscribers? No,
195
+ # we shall wait until all the internal updates are finished
196
+ if not self._is_notifying:
197
+ self._notify_external_subscribers()
198
+
199
+ logger.debug(f"New value for {self.name} is {self._value}")
200
+
201
+ def _correct_type(self, value: tc.ANY_TYPE) -> tc.ANY_TYPE:
202
+ """Convert a value to the DataItem's configured type.
203
+
204
+ Args:
205
+ value: Value.
206
+
207
+ Returns:
208
+ Converted value, or ``int`` when conversion fails.
209
+ """
210
+ conversion_function = self.w3c_xml_data_type.converse
211
+ if conversion_function is not None:
212
+ try:
213
+ return conversion_function(value)
214
+ except (ValueError, TypeError) as e:
215
+ logger.warning(
216
+ f"{self.name} - converting '{value}' to "
217
+ f"type {self.w3c_xml_data_type}: {e}",
218
+ )
219
+ # tbd: understand this - it magically works - why?
220
+ return int
221
+ else:
222
+ exc_msg = f"data type {self.w3c_xml_data_type} not supported"
223
+ raise TypeError(exc_msg)
224
+
225
+ def attach_communication_object(
226
+ self,
227
+ communication_object: CommunicationObject,
228
+ ) -> None:
229
+ """Attach an external communication observer.
230
+
231
+ Duplicate observers are ignored. Observers are notified in attachment
232
+ order whenever the DataItem is written.
233
+
234
+ Args:
235
+ communication_object: Communication object that exposes this
236
+ DataItem to an external client.
237
+ """
238
+ if communication_object not in self.communication_objects:
239
+ self.communication_objects.append(communication_object)
240
+
241
+
242
+ class DataItemCollection(MutableMapping[str, DataItem]):
243
+ """Key-immutable, dict-like container of [DataItem][mtppy.utils.data_item.DataItem]
244
+ instances.
245
+
246
+ The collection is populated at construction time from the ClassVar
247
+ ``CLASS_DATA_ITEMS`` mapping declared statically on the owning class
248
+ (and all of its bases in MRO order). Each entry is deep-copied so that two
249
+ collections derived from the same class do not share DataItems.
250
+
251
+ The collection supports both mapping-style (``da["WQC"]``) and
252
+ attribute-style (``da.WQC``) access to the current *value* of
253
+ a DataItem.
254
+
255
+ Both the dict-keys and the attributes of the class are immutable, that is
256
+ a lhs ``da["NEW"]`` does not add a new entry with key "NDW" to the
257
+ DataItemCollection. The same is true for lhs da.new or self.new - this
258
+ hardens the class against typos.
259
+
260
+ Note:
261
+ In cases where the SNAKE_CASE string holding constant is not distinguishable
262
+ from the CamelCase PNO MTP 2.0 name, our best practice is to have the constant
263
+ name end with an _, e.g. WQC_, P_, PV_
264
+
265
+ Attention:
266
+ The collection is key-immutable and attribute-immutable. Setting an unknown
267
+ key or attribute raises [KeyError][keyerror], while reading an unknown
268
+ attribute raises [AttributeError][attributeerror]. Consequently,
269
+ ``hasattr(collection, name)`` returns ``False`` for missing attributes,
270
+ as it does for normal Python objects.
271
+ """
272
+
273
+ def __init__(self) -> None:
274
+ """Initialize the collection from the class-level ``CLASS_DATA_ITEMS``.
275
+
276
+ Walks the class MRO in reverse order so that base-class entries are
277
+ inserted first and subclass entries take precedence on duplicate
278
+ keys. Each declared data item is deep-copied to avoid shared state
279
+ between instances.
280
+ """
281
+ self._data_items: dict[str, DataItem] = {} # "SrcChannel" : ...
282
+ for cls in reversed(self.__class__.__mro__):
283
+ params = getattr(cls, "CLASS_DATA_ITEMS", None)
284
+ if params is None:
285
+ continue
286
+ for key, data_item in params.items():
287
+ self._data_items[key] = copy.deepcopy(data_item)
288
+
289
+ # whenever a public attribute is added to this class it
290
+ # needs to be added in __setattr__ to the currently
291
+ # empty list if name in ()
292
+
293
+ def __setitem__(self, key: str, value: tc.ANY_TYPE) -> None:
294
+ """Set the value of an existing data item.
295
+
296
+ Args:
297
+ key: The data item name. Must already exist in the collection.
298
+ value: The new value. It is converted to the data item's
299
+ declared type via its subscription/value setter.
300
+
301
+ Raises:
302
+ KeyError: If ``key`` is not a known data item of the collection
303
+ (the collection is key-immutable).
304
+ """
305
+ if key in self._data_items:
306
+ self._data_items[key].set_value(value)
307
+ else:
308
+ exc_msg = f"Cannot add new key key='{key}', assembly is key-immutable."
309
+ raise KeyError(exc_msg)
310
+
311
+ def __delitem__(self, key: str) -> None:
312
+ """Delete a data item from the collection.
313
+
314
+ Args:
315
+ key: The data item name.
316
+
317
+ Raises:
318
+ TypeError: Always. The collection is key-immutable and does
319
+ not support deletion of items.
320
+ """
321
+ exc_msg = f"delete attempt key='{key}': assembly is key-immutable."
322
+ raise TypeError(exc_msg)
323
+
324
+ # Standard MutableMapping interface
325
+ def __getitem__(self, key: str) -> tc.ANY_TYPE:
326
+ """Return the current value of the data item identified by ``key``.
327
+
328
+ Args:
329
+ key: The data item name.
330
+
331
+ Returns:
332
+ The current value of the data item (already type-cast by the
333
+ underlying [DataItem][mtppy.utils.data_item.DataItem]).
334
+ """
335
+ return self._data_items[key].get_value()
336
+
337
+ def __iter__(self) -> Iterator[str]:
338
+ """Return an iterator over the data item names.
339
+
340
+ Returns:
341
+ An iterator yielding the registered data item keys.
342
+ """
343
+ return iter(self._data_items)
344
+
345
+ def __len__(self) -> int:
346
+ """Return the number of data items in the collection.
347
+
348
+ Returns:
349
+ The number of registered data items.
350
+ """
351
+ return len(self._data_items)
352
+
353
+ def keys(self) -> KeysView[str]:
354
+ """Return a view of the data item names.
355
+
356
+ Returns:
357
+ A ``KeysView`` of the registered data item keys.
358
+ """
359
+ return self._data_items.keys()
360
+
361
+ def values(self) -> list[tc.ANY_TYPE]:
362
+ """Return the current values of all data items.
363
+
364
+ Returns:
365
+ A list with the current value of every data item, in the same
366
+ order as `keys`.
367
+ """
368
+ return [item.get_value() for item in self._data_items.values()]
369
+
370
+ def items(self) -> list[tuple[str, tc.ANY_TYPE]]:
371
+ """Return the current ``{name: value}`` dict of all data items.
372
+
373
+ Returns:
374
+ A list of ``(key, value)`` tuples for every data item, where
375
+ ``value`` is the current (already type-cast) value.
376
+ """
377
+ return [(k, v.get_value()) for k, v in self._data_items.items()]
378
+
379
+ def get_item(self, key: str) -> DataItem:
380
+ """Access the underlying [DataItem][mtppy.utils.data_item.DataItem] for
381
+ introspection.
382
+
383
+ Unlike [``__getitem__``][], this returns the full DataItem object
384
+ instead of only its current value, allowing callers to inspect or
385
+ modify attributes such as the data type, access mode or
386
+ subscription callback.
387
+
388
+ Args:
389
+ key: The data item name.
390
+
391
+ Returns:
392
+ The [DataItem][mtppy.utils.data_item.DataItem] instance registered
393
+ under ``key``.
394
+ """
395
+ return self._data_items[key]
396
+
397
+ def __getattr__(self, name: str) -> tc.ANY_TYPE:
398
+ """Enable both dict style on the rhs, that is dict-style
399
+ (``da["OSLevel"]``) and attribute-style (``da.OSLevel``) read access.
400
+
401
+ Missing attributes raise ``AttributeError`` so that ``hasattr`` follows
402
+ normal Python behavior.
403
+ """
404
+ try:
405
+ if name in self._data_items:
406
+ return self._data_items[name].get_value()
407
+ key = getattr(self.__class__, name)
408
+ except AttributeError as exc:
409
+ exc_msg = f"DataItemCollection has no attribute '{name}'"
410
+ raise AttributeError(exc_msg) from exc
411
+ if key in self._data_items:
412
+ return self._data_items[key].get_value()
413
+ exc_msg = f"DataItemCollection has no attribute '{name}'"
414
+ raise AttributeError(exc_msg)
415
+
416
+ def __setattr__(self, name: str, value: tc.ANY_TYPE) -> None:
417
+ """Enable both lhs dict style (``da["OSLevel"]``) and
418
+ attribute-style (`da.OSLevel``) write access.
419
+
420
+ Internal attributes and private names are stored
421
+ normally. Everything else is mapped to the matching class
422
+ constant key, if one exists, and validated/cast through the
423
+ underlying ``DataItem``.
424
+ """
425
+ if name.startswith("_"): # or name in (set of protected names)
426
+ super().__setattr__(name, value)
427
+ return
428
+ if name in self._data_items:
429
+ self._data_items[name].set_value(value)
430
+ elif any(name in cls.__dict__ for cls in type(self).__mro__) or any(
431
+ name in getattr(cls, "__annotations__", {}) for cls in type(self).__mro__
432
+ ):
433
+ super().__setattr__(name, value)
434
+ else:
435
+ exc_msg = f"Cannot add unknown attribute '{name}' to data item collection."
436
+ raise KeyError(exc_msg)
437
+
438
+ def __repr__(self) -> str:
439
+ """Return an unambiguous string representation of the collection.
440
+
441
+ Returns:
442
+ A string of the form ``"data_items={...}"`` containing the
443
+ current ``name -> value`` mapping of every registered data item.
444
+ """
445
+ params = {k: v.get_value() for k, v in self._data_items.items()}
446
+ return f"class={self.__class__.__name__},data_items={params})"
@@ -0,0 +1,82 @@
1
+ """Test Suite for DataItem and OPCUACommunicationObject."""
2
+
3
+ __copyright__ = (
4
+ "Copyright (c) 2026 Dresden University of Technology, Process-to-Order Group"
5
+ )
6
+ __license__ = "MIT"
7
+
8
+ from unittest.mock import Mock
9
+
10
+ from mtppy.part3.definitions.mtp_access import MTPAccess
11
+ from mtppy.part5.communication_object import (
12
+ MODBUSCommunicationObject,
13
+ OPCUACommunicationObject,
14
+ )
15
+ from mtppy.part5.definitions.w3c_xml_data_types import W3cXmlDataTypes
16
+ from mtppy.utils.data_item import DataItem
17
+
18
+
19
+ def test_internal_write_triggers_only_subscriber_callbacks():
20
+ # Mock the subscription callback
21
+ mock_ext_wrt_cb = Mock()
22
+
23
+ # Mock the OPCUACommunicationObject
24
+ mock_comm_obj = Mock(spec=OPCUACommunicationObject)
25
+ mock_comm_obj.write_value_callback = Mock()
26
+ mock_comm_obj._node_id = "dummy"
27
+
28
+ # Create a DataItem instance with the mocked subscription callback
29
+ data_item = DataItem(
30
+ name="TestItem",
31
+ w3c_xml_data_type=W3cXmlDataTypes.INT,
32
+ access=MTPAccess.ReadWriteAccess,
33
+ init_value=0,
34
+ external_write_cb=mock_ext_wrt_cb,
35
+ )
36
+
37
+ # Attach the mocked communication object
38
+ data_item.attach_communication_object(mock_comm_obj)
39
+
40
+ # Set a new value and ensure callbacks are triggered
41
+ new_value = 42
42
+ data_item.set_value(new_value)
43
+
44
+ # Assert that the subscription callback was never called
45
+ mock_ext_wrt_cb.assert_not_called
46
+
47
+ # Assert that the communication object's write_value_callback was called with the correct value
48
+ mock_comm_obj.write_value_callback.assert_called_once_with(new_value)
49
+
50
+
51
+ def test_modbus_communication_object_writes_data_item_value_to_register() -> None:
52
+ modbus_client = Mock()
53
+ communication_object = MODBUSCommunicationObject(modbus_client, "42")
54
+ data_item = DataItem(
55
+ name="TestItem",
56
+ w3c_xml_data_type=W3cXmlDataTypes.INT,
57
+ access=MTPAccess.ReadWriteAccess,
58
+ init_value=0,
59
+ )
60
+
61
+ data_item.attach_communication_object(communication_object)
62
+ data_item.set_value(7)
63
+
64
+ modbus_client.write_register.assert_called_once_with(address=42, value=7)
65
+
66
+
67
+ def test_data_item_notifies_all_attached_communication_objects() -> None:
68
+ first_communication_object = Mock()
69
+ second_communication_object = Mock()
70
+ data_item = DataItem(
71
+ name="TestItem",
72
+ w3c_xml_data_type=W3cXmlDataTypes.INT,
73
+ access=MTPAccess.ReadWriteAccess,
74
+ init_value=0,
75
+ )
76
+
77
+ data_item.attach_communication_object(first_communication_object)
78
+ data_item.attach_communication_object(second_communication_object)
79
+ data_item.set_value(7)
80
+
81
+ first_communication_object.write_value_callback.assert_called_once_with(7)
82
+ second_communication_object.write_value_callback.assert_called_once_with(7)