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.
- examples/__init__.py +0 -0
- examples/minimal+pid.py +82 -0
- examples/minimal.py +32 -0
- examples/minimal_recipe.py +117 -0
- examples/pea_alarm_base.py +47 -0
- examples/pea_minimal.py +258 -0
- mtppy/__init__.py +1 -0
- mtppy/part1/__init__.py +4 -0
- mtppy/part1/empty_folder_test.py +7 -0
- mtppy/part3/__init__.py +4 -0
- mtppy/part3/data_assembly_set/__init__.py +4 -0
- mtppy/part3/data_assembly_set/active_elements/__init__.py +4 -0
- mtppy/part3/data_assembly_set/active_elements/active_element.py +22 -0
- mtppy/part3/data_assembly_set/active_elements/drives/__init__.py +4 -0
- mtppy/part3/data_assembly_set/active_elements/drives/ana_drv.py +446 -0
- mtppy/part3/data_assembly_set/active_elements/drives/ana_drv_test.py +567 -0
- mtppy/part3/data_assembly_set/active_elements/drives/bin_drv.py +365 -0
- mtppy/part3/data_assembly_set/active_elements/drives/bin_drv_test.py +503 -0
- mtppy/part3/data_assembly_set/active_elements/drives/mon_ana_drv.py +469 -0
- mtppy/part3/data_assembly_set/active_elements/drives/mon_ana_drv_test.py +418 -0
- mtppy/part3/data_assembly_set/active_elements/drives/mon_bin_drv.py +295 -0
- mtppy/part3/data_assembly_set/active_elements/drives/mon_bin_drv_test.py +265 -0
- mtppy/part3/data_assembly_set/active_elements/pid/__init__.py +4 -0
- mtppy/part3/data_assembly_set/active_elements/pid/pid_ctrl.py +333 -0
- mtppy/part3/data_assembly_set/active_elements/pid/pid_ctrl_test.py +156 -0
- mtppy/part3/data_assembly_set/active_elements/valves/__init__.py +4 -0
- mtppy/part3/data_assembly_set/active_elements/valves/ana_vlv.py +402 -0
- mtppy/part3/data_assembly_set/active_elements/valves/ana_vlv_test.py +732 -0
- mtppy/part3/data_assembly_set/active_elements/valves/bin_vlv.py +279 -0
- mtppy/part3/data_assembly_set/active_elements/valves/bin_vlv_test.py +585 -0
- mtppy/part3/data_assembly_set/active_elements/valves/mon_ana_vlv.py +402 -0
- mtppy/part3/data_assembly_set/active_elements/valves/mon_ana_vlv_test.py +270 -0
- mtppy/part3/data_assembly_set/active_elements/valves/mon_bin_vlv.py +286 -0
- mtppy/part3/data_assembly_set/active_elements/valves/mon_bin_vlv_test.py +224 -0
- mtppy/part3/data_assembly_set/base_functions/__init__.py +4 -0
- mtppy/part3/data_assembly_set/base_functions/operation_mode.py +252 -0
- mtppy/part3/data_assembly_set/base_functions/operation_mode_test.py +260 -0
- mtppy/part3/data_assembly_set/base_functions/oslevel.py +35 -0
- mtppy/part3/data_assembly_set/base_functions/source_mode.py +189 -0
- mtppy/part3/data_assembly_set/base_functions/source_mode_test.py +112 -0
- mtppy/part3/data_assembly_set/base_functions/wqc.py +75 -0
- mtppy/part3/data_assembly_set/base_functions/wqc_test.py +86 -0
- mtppy/part3/data_assembly_set/data_assembly.py +65 -0
- mtppy/part3/data_assembly_set/diagnostic_elements/__init__.py +4 -0
- mtppy/part3/data_assembly_set/diagnostic_elements/diagnostic_element.py +19 -0
- mtppy/part3/data_assembly_set/indicator_elements/__init__.py +4 -0
- mtppy/part3/data_assembly_set/indicator_elements/indicator_element.py +22 -0
- mtppy/part3/data_assembly_set/indicator_elements/views/__init__.py +4 -0
- mtppy/part3/data_assembly_set/indicator_elements/views/ana_view.py +68 -0
- mtppy/part3/data_assembly_set/indicator_elements/views/bin_view.py +73 -0
- mtppy/part3/data_assembly_set/indicator_elements/views/dint_view.py +76 -0
- mtppy/part3/data_assembly_set/indicator_elements/views/string_view.py +55 -0
- mtppy/part3/data_assembly_set/operation_elements/__init__.py +4 -0
- mtppy/part3/data_assembly_set/operation_elements/operation_element.py +19 -0
- mtppy/part3/data_assembly_set/pea_elements/__init__.py +4 -0
- mtppy/part3/data_assembly_set/pea_elements/pea_element.py +28 -0
- mtppy/part3/data_assembly_set/pea_elements/pea_information_label/__init__.py +4 -0
- mtppy/part3/data_assembly_set/pea_elements/pea_information_label/pea_information_label.py +231 -0
- mtppy/part3/data_assembly_set/pea_elements/web_server_url_info/__init__.py +4 -0
- mtppy/part3/data_assembly_set/pea_elements/web_server_url_info/web_server_url_info.py +42 -0
- mtppy/part3/definitions/__init__.py +4 -0
- mtppy/part3/definitions/mtp_access.py +15 -0
- mtppy/part4/__init__.py +4 -0
- mtppy/part4/automation_services/__init__.py +4 -0
- mtppy/part4/automation_services/base_functions/__init__.py +4 -0
- mtppy/part4/automation_services/base_functions/service_operation_mode.py +368 -0
- mtppy/part4/automation_services/base_functions/service_operation_mode_test.py +242 -0
- mtppy/part4/automation_services/base_functions/service_operator_interaction.py +62 -0
- mtppy/part4/automation_services/base_functions/service_source_mode.py +138 -0
- mtppy/part4/automation_services/base_functions/service_source_mode_test.py +179 -0
- mtppy/part4/automation_services/helpers/__init__.py +4 -0
- mtppy/part4/automation_services/helpers/command_en_control.py +330 -0
- mtppy/part4/automation_services/helpers/command_en_control_test.py +302 -0
- mtppy/part4/automation_services/helpers/state_machine.py +167 -0
- mtppy/part4/automation_services/helpers/state_machine_test.py +294 -0
- mtppy/part4/automation_services/helpers/thread_control.py +39 -0
- mtppy/part4/automation_services/helpers/thread_control_test.py +80 -0
- mtppy/part4/automation_services/procedure.py +111 -0
- mtppy/part4/automation_services/procedure_test.py +185 -0
- mtppy/part4/automation_services/service.py +463 -0
- mtppy/part4/automation_services/service_elements/__init__.py +4 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/__init__.py +4 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/ana_serv_param.py +108 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/ana_serv_param_test.py +59 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/bin_serv_param.py +70 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/bin_serv_param_test.py +39 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/common_serv_param.py +111 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/dint_serv_param.py +77 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/dint_serv_param_test.py +58 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/parameter_element.py +175 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/parameter_element_test.py +246 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/string_serv_param.py +48 -0
- mtppy/part4/automation_services/service_elements/parameter_elements/string_serv_param_test.py +37 -0
- mtppy/part4/automation_services/service_elements/procedure_health_view.py +36 -0
- mtppy/part4/automation_services/service_elements/service_control.py +473 -0
- mtppy/part4/automation_services/service_elements/service_control_test.py +150 -0
- mtppy/part4/automation_services/service_elements/service_element.py +20 -0
- mtppy/part4/automation_services/service_test.py +407 -0
- mtppy/part4/definitions/__init__.py +4 -0
- mtppy/part4/definitions/command_codes.py +58 -0
- mtppy/part4/definitions/command_codes_test.py +44 -0
- mtppy/part4/definitions/state_codes.py +65 -0
- mtppy/part4/definitions/state_codes_test.py +44 -0
- mtppy/part4/process_values/__init__.py +4 -0
- mtppy/part4/process_values/base_functions/__init__.py +4 -0
- mtppy/part4/process_values/base_functions/vqc.py +48 -0
- mtppy/part4/process_values/input_elements/__init__.py +4 -0
- mtppy/part4/process_values/input_elements/ana_process_value_in.py +160 -0
- mtppy/part4/process_values/input_elements/bin_process_value_in.py +83 -0
- mtppy/part4/process_values/input_elements/dint_process_value_in.py +159 -0
- mtppy/part4/process_values/input_elements/input_element.py +40 -0
- mtppy/part4/process_values/input_elements/string_process_value_in.py +45 -0
- mtppy/part5/__init__.py +4 -0
- mtppy/part5/communication_object.py +99 -0
- mtppy/part5/communication_object_test.py +59 -0
- mtppy/part5/definitions/__init__.py +4 -0
- mtppy/part5/definitions/iec61131_data_types.py +31 -0
- mtppy/part5/definitions/iec61131_data_types_test.py +82 -0
- mtppy/part5/definitions/opcua_data_types.py +36 -0
- mtppy/part5/definitions/opcua_data_types_test.py +90 -0
- mtppy/part5/definitions/w3c_xml_data_types.py +177 -0
- mtppy/part5/definitions/w3c_xml_data_types_test.py +143 -0
- mtppy/part5/server_assembly_set/__init__.py +5 -0
- mtppy/part6/__init__.py +4 -0
- mtppy/part6/alarm_set/__init__.py +4 -0
- mtppy/part6/alarm_set/alarm.py +132 -0
- mtppy/part6/alarm_set/alarm_test.py +265 -0
- mtppy/pea/__init__.py +19 -0
- mtppy/pea/data_assembly_factory.py +124 -0
- mtppy/pea/data_assembly_factory_test.py +229 -0
- mtppy/pea/mtp_generator/__init__.py +4 -0
- mtppy/pea/mtp_generator/mtp_generator.py +1183 -0
- mtppy/pea/mtp_generator/mtp_generator_test.py +518 -0
- mtppy/pea/pea.py +139 -0
- mtppy/pea/pea_opcua_server/__init__.py +4 -0
- mtppy/pea/pea_opcua_server/marshalling.py +60 -0
- mtppy/pea/pea_opcua_server/opcua_options_provider/__init__.py +4 -0
- mtppy/pea/pea_opcua_server/opcua_options_provider/instances/basic_opcua_options_provider.py +50 -0
- mtppy/pea/pea_opcua_server/opcua_options_provider/opcua_options_provider.py +103 -0
- mtppy/pea/pea_opcua_server/pea_opcua_server.py +492 -0
- mtppy/pea/pea_opcua_server/subscription_list.py +55 -0
- mtppy/pea/pea_test.py +59 -0
- mtppy/pea/rmtp_pea.py +155 -0
- mtppy/pea/rmtp_pea_test.py +27 -0
- mtppy/pea/service_factory.py +178 -0
- mtppy/pea/service_factory_test.py +156 -0
- mtppy/pea/user_logic/__init__.py +4 -0
- mtppy/pea/user_logic/user_logic_pea.py +72 -0
- mtppy/pea/user_logic/user_logic_registry.py +106 -0
- mtppy/pea/user_logic/user_logic_registry_test.py +167 -0
- mtppy/pea/user_logic/user_logic_service.py +228 -0
- mtppy/pea/user_logic/user_logic_service_test.py +289 -0
- mtppy/utils/__init__.py +4 -0
- mtppy/utils/data_assembly_dict.py +61 -0
- mtppy/utils/data_assembly_dict_test.py +107 -0
- mtppy/utils/data_item.py +446 -0
- mtppy/utils/data_item_com_test.py +82 -0
- mtppy/utils/data_item_test.py +325 -0
- mtppy/utils/logging.py +23 -0
- mtppy/utils/logging_test.py +26 -0
- mtppy/utils/mtp_file_handling/__init__.py +4 -0
- mtppy/utils/mtp_file_handling/extract_manifest_from_mtp_file.py +22 -0
- mtppy/utils/mtp_file_handling/extract_mtp_file.py +22 -0
- mtppy/utils/mtp_file_handling/is_valid_mtp.py +22 -0
- mtppy/utils/mtp_file_handling/is_valid_path_for_manifest.py +15 -0
- mtppy/utils/mtp_file_handling/is_valid_path_to_manifest.py +17 -0
- mtppy/utils/mtp_file_handling/is_valid_path_to_mtp.py +17 -0
- mtppy/utils/mtp_file_handling/load_manifest_file.py +24 -0
- mtppy/utils/mtp_file_handling/mtp_reader/__init__.py +4 -0
- mtppy/utils/mtp_file_handling/mtp_reader/mtp_reader.py +103 -0
- mtppy/utils/mtp_file_handling/mtp_reader/mtp_reader_test.py +96 -0
- mtppy/utils/mtp_file_handling/mtp_reader/mtp_xml.py +226 -0
- mtppy/utils/mtp_file_handling/mtp_reader/mtp_xml_test.py +108 -0
- mtppy/utils/mtp_file_handling/mtp_writer/__init__.py +4 -0
- mtppy/utils/mtp_file_handling/mtp_writer/mtp_writer.py +77 -0
- mtppy/utils/mtp_file_handling/mtp_writer/ressources/get_content_types.py +10 -0
- mtppy/utils/mtp_file_handling/mtp_writer/ressources/get_rels.py +11 -0
- mtppy/utils/mtp_file_handling/mtp_writer/test_mtp_writer.py +25 -0
- mtppy/utils/mtp_file_handling/write_manifest_file.py +29 -0
- mtppy/utils/mtp_file_handling/write_manifest_file_test.py +22 -0
- mtppy/utils/mtp_file_handling/write_mtp_file.py +43 -0
- mtppy/utils/mtp_file_handling/write_mtp_file_test.py +22 -0
- mtppy/utils/testing.py +31 -0
- mtppy2_0-0.1.5.dist-info/METADATA +75 -0
- mtppy2_0-0.1.5.dist-info/RECORD +214 -0
- mtppy2_0-0.1.5.dist-info/WHEEL +5 -0
- mtppy2_0-0.1.5.dist-info/entry_points.txt +3 -0
- mtppy2_0-0.1.5.dist-info/licenses/LICENSE +21 -0
- mtppy2_0-0.1.5.dist-info/top_level.txt +4 -0
- tools/__init__.py +0 -0
- tools/create_bugreport.py +150 -0
- tools/inspect_mtp.py +110 -0
- utils/__init__.py +1 -0
- utils/file_handling/__init__.py +4 -0
- utils/file_handling/cleanup_folder.py +9 -0
- utils/file_handling/get_utc_foldername_with_ms.py +15 -0
- utils/file_handling/get_work_dir.py +9 -0
- utils/file_handling/is_valid_path_for_json.py +11 -0
- utils/file_handling/is_valid_path_to_json.py +17 -0
- utils/file_handling/load_json_file.py +18 -0
- utils/file_handling/read_file_from_file_archive.py +43 -0
- utils/file_handling/read_file_string.py +10 -0
- utils/file_handling/validate_json_data.py +28 -0
- utils/file_handling/write_json_file.py +23 -0
- utils/get_uuid.py +18 -0
- utils/get_uuid_test.py +17 -0
- utils/logging.py +100 -0
- utils/logging_test.py +67 -0
- utils/opcua/check_node.py +79 -0
- utils/resolve_unit/resolve_unit.py +11 -0
- utils/resolve_unit/unit_lib.py +2101 -0
- utils/resolve_unit/unit_lib_test.py +36 -0
- utils/type_conversion.py +236 -0
- utils/type_conversion_test.py +114 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Implementation of the PEA opcua server."""
|
|
2
|
+
|
|
3
|
+
__copyright__ = (
|
|
4
|
+
"Copyright (c) 2026 Dresden University of Technology, Process-to-Order Group"
|
|
5
|
+
)
|
|
6
|
+
__license__ = "MIT"
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
from opcua import Server, ua
|
|
10
|
+
|
|
11
|
+
from mtppy.utils.logging import logger
|
|
12
|
+
from mtppy.pea.pea_opcua_server.opcua_options_provider.opcua_options_provider import (
|
|
13
|
+
OPCUAOptionsProvider,
|
|
14
|
+
)
|
|
15
|
+
from mtppy.pea.pea_opcua_server.marshalling import Marshalling
|
|
16
|
+
from mtppy.part5.communication_object import OPCUACommunicationObject
|
|
17
|
+
from mtppy.part3.data_assembly_set.data_assembly import DataAssembly
|
|
18
|
+
from mtppy.pea.pea_opcua_server.subscription_list import SubscriptionList
|
|
19
|
+
from mtppy.part3.definitions.mtp_access import MTPAccess
|
|
20
|
+
from mtppy.part5.definitions.w3c_xml_data_types import W3cXmlDataTypes
|
|
21
|
+
from mtppy.part4.automation_services.procedure import Procedure
|
|
22
|
+
from mtppy.part4.automation_services.service import Service
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PeaOPCUAServer:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
opcua_options_provider: OPCUAOptionsProvider,
|
|
29
|
+
endpoint: str = "opc.tcp://localhost:4840/",
|
|
30
|
+
) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Initializes an OPC UA server for PEA.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
opcua_options_provider: Instance of an OPCUAOptionsProvider.
|
|
36
|
+
endpoint: Endpoint of the OPC UA server. If not specified, opc.tcp://localhost:4840/ is used.
|
|
37
|
+
"""
|
|
38
|
+
self.endpoint = endpoint
|
|
39
|
+
self.opcua_server = None
|
|
40
|
+
self.opcua_ns = None
|
|
41
|
+
self.opcua_ns_index = None
|
|
42
|
+
self.opcua_options_provider = opcua_options_provider
|
|
43
|
+
self.subscription_list = SubscriptionList()
|
|
44
|
+
|
|
45
|
+
def init_opcua_server(self) -> None:
|
|
46
|
+
"""Initializes an OPC UA server."""
|
|
47
|
+
logger.opcua(f"Initializing PEA OPC UA server: {self.endpoint}")
|
|
48
|
+
self.opcua_server = Server()
|
|
49
|
+
self.opcua_server.set_endpoint(self.endpoint)
|
|
50
|
+
|
|
51
|
+
# set all possible endpoint policies for clients to connect through
|
|
52
|
+
self.opcua_server.set_security_policy(
|
|
53
|
+
[
|
|
54
|
+
ua.SecurityPolicyType.NoSecurity,
|
|
55
|
+
# ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt,
|
|
56
|
+
# ua.SecurityPolicyType.Basic256Sha256_Sign,
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# init our own namespace
|
|
61
|
+
namespace_uri = self.opcua_options_provider.get_default_namespace_uri()
|
|
62
|
+
self.opcua_ns_index = self.opcua_server.register_namespace(namespace_uri)
|
|
63
|
+
|
|
64
|
+
def _get_opcua_server(self):
|
|
65
|
+
"""
|
|
66
|
+
Gets OPC UA server instance object.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The OPC UA server instance.
|
|
70
|
+
"""
|
|
71
|
+
return self.opcua_server
|
|
72
|
+
|
|
73
|
+
def _get_opcua_ns_index(self) -> int:
|
|
74
|
+
"""
|
|
75
|
+
Gets OPC UA server namespace index of default namespace uri.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The OPC UA server namespace index.
|
|
79
|
+
"""
|
|
80
|
+
return self.opcua_ns_index
|
|
81
|
+
|
|
82
|
+
def get_node_part_ns_index(self) -> str:
|
|
83
|
+
"""Get OPC UA server namespace index of default namespace uri.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The OPC UA server namespace index as a string.
|
|
87
|
+
"""
|
|
88
|
+
return f"ns={self.opcua_ns_index};"
|
|
89
|
+
|
|
90
|
+
def _get_opcua_ns_uri(self):
|
|
91
|
+
"""Get OPC UA server default namespace.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The OPC UA server default namespace.
|
|
95
|
+
"""
|
|
96
|
+
return self.opcua_options_provider.get_default_namespace_uri()
|
|
97
|
+
|
|
98
|
+
def run_opcua_server(self, data_assemblies, service_set):
|
|
99
|
+
"""Start the OPC UA server instance."""
|
|
100
|
+
self.opcua_server.start()
|
|
101
|
+
self._build_opcua_server(data_assemblies, service_set)
|
|
102
|
+
self._start_subscription()
|
|
103
|
+
|
|
104
|
+
def _build_opcua_server(self, data_assemblies, service_set):
|
|
105
|
+
"""Create an OPC UA server instance adding required nodes according to
|
|
106
|
+
defined DataAssemblies."""
|
|
107
|
+
logger.opcua(f"Adding OPC UA nodes")
|
|
108
|
+
server = self.opcua_server.get_objects_node()
|
|
109
|
+
|
|
110
|
+
# Iterate over DataAssemblies
|
|
111
|
+
root_node_data_assemblies_folder = server.add_folder(
|
|
112
|
+
f"{self.get_node_part_ns_index()}s=data_assemblies", "data_assemblies"
|
|
113
|
+
)
|
|
114
|
+
for data_assembly_instance in data_assemblies.values():
|
|
115
|
+
logger.opcua(f"- DataAssembly {data_assembly_instance.tag_name}")
|
|
116
|
+
self._create_opcua_objects_for_data_assembly(
|
|
117
|
+
data_assembly_instance, root_node_data_assemblies_folder
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Iterate over services
|
|
121
|
+
root_node_services_folder = server.add_folder(
|
|
122
|
+
f"{self.get_node_part_ns_index()}s=services", "services"
|
|
123
|
+
)
|
|
124
|
+
for service_instance in service_set.values():
|
|
125
|
+
logger.opcua(f"- Service {service_instance.name}")
|
|
126
|
+
self._create_opcua_objects_for_service(
|
|
127
|
+
service_instance, root_node_services_folder
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _create_opcua_objects_for_service(self, service_instance: Service, root_node):
|
|
131
|
+
|
|
132
|
+
root_node_service_folder = root_node.add_folder(
|
|
133
|
+
f"{self.get_node_part_ns_index()}s=service.{service_instance.name}",
|
|
134
|
+
service_instance.name,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
logger.opcua(f"- Service Control {service_instance.service_control.tag_name}")
|
|
138
|
+
self._create_opcua_objects_for_data_assembly(
|
|
139
|
+
service_instance.service_control, root_node_service_folder
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if service_instance.configuration_parameters:
|
|
143
|
+
root_node_service_config_param_folder = root_node_service_folder.add_folder(
|
|
144
|
+
f"{self.get_node_part_ns_index()}s=service.{service_instance.name}.configuration_parameters",
|
|
145
|
+
"configuration_parameters",
|
|
146
|
+
)
|
|
147
|
+
for (
|
|
148
|
+
configuration_parameter
|
|
149
|
+
) in service_instance.configuration_parameters.values():
|
|
150
|
+
logger.opcua(
|
|
151
|
+
f"- Service Configuration Parameter {configuration_parameter.tag_name}"
|
|
152
|
+
)
|
|
153
|
+
self._create_opcua_objects_for_data_assembly(
|
|
154
|
+
configuration_parameter, root_node_service_config_param_folder
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
root_node_service_procedures_folder = root_node_service_folder.add_folder(
|
|
158
|
+
f"{self.get_node_part_ns_index()}s=service.{service_instance.name}.procedures",
|
|
159
|
+
"procedures",
|
|
160
|
+
)
|
|
161
|
+
for procedure in service_instance._get_procedures().values():
|
|
162
|
+
logger.opcua(f"- Service Procedure {procedure.name}")
|
|
163
|
+
self._create_opcua_objects_for_procedure(
|
|
164
|
+
procedure, root_node_service_procedures_folder, service_instance.name
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _create_opcua_objects_for_procedure(
|
|
168
|
+
self, procedure: Procedure, root_node, service_name: str
|
|
169
|
+
):
|
|
170
|
+
|
|
171
|
+
root_node_procedure_folder = root_node.add_folder(
|
|
172
|
+
f"{self.get_node_part_ns_index()}s=service.{service_name}.procedures.{procedure.name}",
|
|
173
|
+
procedure.name,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
logger.opcua(f"- Procedure {procedure.name}")
|
|
177
|
+
self._create_opcua_objects_for_data_assembly(
|
|
178
|
+
procedure.procedure_health_view, root_node_procedure_folder
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if procedure.procedure_parameters:
|
|
182
|
+
root_node_service_proc_param_folder = root_node_procedure_folder.add_folder(
|
|
183
|
+
f"{self.get_node_part_ns_index()}s=service.{service_name}.procedures.{procedure.name}.procedure_parameters",
|
|
184
|
+
"procedure_parameters",
|
|
185
|
+
)
|
|
186
|
+
for procedure_parameter in procedure.procedure_parameters.values():
|
|
187
|
+
logger.opcua(
|
|
188
|
+
f"- Procedure ProcedureParameter {procedure_parameter.tag_name}"
|
|
189
|
+
)
|
|
190
|
+
self._create_opcua_objects_for_data_assembly(
|
|
191
|
+
procedure_parameter, root_node_service_proc_param_folder
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if procedure.process_value_ins:
|
|
195
|
+
root_node_service_process_value_ins_folder = root_node_procedure_folder.add_folder(
|
|
196
|
+
f"{self.get_node_part_ns_index()}s=service.{service_name}.procedures.{procedure.name}.process_value_ins",
|
|
197
|
+
"process_value_ins",
|
|
198
|
+
)
|
|
199
|
+
for process_value_in in procedure.process_value_ins.values():
|
|
200
|
+
logger.opcua(f"- Procedure ProcessValueIn {process_value_in.tag_name}")
|
|
201
|
+
self._create_opcua_objects_for_data_assembly(
|
|
202
|
+
process_value_in, root_node_service_process_value_ins_folder
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if procedure.process_value_outs:
|
|
206
|
+
root_node_service_process_value_outs_folder = root_node_procedure_folder.add_folder(
|
|
207
|
+
f"{self.get_node_part_ns_index()}s=service.{service_name}.procedures.{procedure.name}.process_value_outs",
|
|
208
|
+
"process_value_outs",
|
|
209
|
+
)
|
|
210
|
+
for process_value_out in procedure.process_value_outs.values():
|
|
211
|
+
logger.opcua(
|
|
212
|
+
f"- Procedure ProcessValueOut {process_value_out.tag_name}"
|
|
213
|
+
)
|
|
214
|
+
self._create_opcua_objects_for_data_assembly(
|
|
215
|
+
process_value_out, root_node_service_process_value_outs_folder
|
|
216
|
+
)
|
|
217
|
+
if procedure.report_values:
|
|
218
|
+
root_node_service_report_values_folder = root_node_procedure_folder.add_folder(
|
|
219
|
+
f"{self.get_node_part_ns_index()}s=service.{service_name}.procedures.{procedure.name}.report_values",
|
|
220
|
+
"report_values",
|
|
221
|
+
)
|
|
222
|
+
for report_value in procedure.report_values.values():
|
|
223
|
+
logger.opcua(f"- Procedure ReportValue {report_value.tag_name}")
|
|
224
|
+
self._create_opcua_objects_for_data_assembly(
|
|
225
|
+
report_value, root_node_service_report_values_folder
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def node_exists(self, nodeid):
|
|
229
|
+
try:
|
|
230
|
+
node = self.opcua_server.get_node(nodeid)
|
|
231
|
+
_ = node.get_browse_name() # Triggers error if node doesn't exist
|
|
232
|
+
return True
|
|
233
|
+
except ua.uaerrors._auto.BadNodeIdUnknown:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
def is_folder(self, nodeid):
|
|
237
|
+
try:
|
|
238
|
+
# Get the NodeClass of the node
|
|
239
|
+
node = self.opcua_server.get_node(nodeid)
|
|
240
|
+
node_class = node.get_node_class()
|
|
241
|
+
if node_class == ua.NodeClass.Object:
|
|
242
|
+
# Check if the ObjectType is FolderType
|
|
243
|
+
object_type = node.get_type_definition()
|
|
244
|
+
if object_type.Identifier == ua.ObjectIds.FolderType:
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
except Exception as e:
|
|
248
|
+
print(f"Error checking if node is a folder: {e}")
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
# root_node_data_assembly = root_node.add_folder( f"{self.get_node_part_ns_index()}s={tag_name}", tag_name)
|
|
252
|
+
def add_folder_if_not_exists(self, root_node, tag_name):
|
|
253
|
+
# Construct the NodeId for the folder
|
|
254
|
+
parent_identifier = root_node.nodeid.Identifier
|
|
255
|
+
folder_identifier = (
|
|
256
|
+
f"{parent_identifier}.{tag_name}"
|
|
257
|
+
if isinstance(parent_identifier, str)
|
|
258
|
+
else tag_name
|
|
259
|
+
)
|
|
260
|
+
node_id = f"{self.get_node_part_ns_index()}s={folder_identifier}"
|
|
261
|
+
children = root_node.get_children()
|
|
262
|
+
|
|
263
|
+
# Check if a child with the desired browse name already exists
|
|
264
|
+
for child in children:
|
|
265
|
+
if (
|
|
266
|
+
child.get_browse_name().Name == tag_name
|
|
267
|
+
and child.nodeid.to_string() == node_id
|
|
268
|
+
):
|
|
269
|
+
logger.opcua(f"Folder '{tag_name}' exists with NodeId: {child.nodeid}")
|
|
270
|
+
return child # Return the existing node
|
|
271
|
+
|
|
272
|
+
# If the folder does not exist, create it
|
|
273
|
+
if self.node_exists(node_id) and self.is_folder(node_id):
|
|
274
|
+
node = self.opcua_server.get_node(node_id)
|
|
275
|
+
root_node.add_reference(node.nodeid, ua.ObjectIds.Organizes)
|
|
276
|
+
logger.opcua(
|
|
277
|
+
f"Folder '{node.get_browse_name().Name}' exists with NodeId: {node.nodeid}"
|
|
278
|
+
)
|
|
279
|
+
return node
|
|
280
|
+
else:
|
|
281
|
+
new_folder = root_node.add_folder(node_id, tag_name)
|
|
282
|
+
logger.opcua(
|
|
283
|
+
f"Folder '{tag_name}' created with NodeId: {new_folder.nodeid}"
|
|
284
|
+
)
|
|
285
|
+
return new_folder
|
|
286
|
+
|
|
287
|
+
def _create_opcua_objects_for_data_assembly(
|
|
288
|
+
self, data_assembly: DataAssembly, root_node
|
|
289
|
+
):
|
|
290
|
+
"""Iterate over DataAssemblies to create OPC UA objects.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
data_assembly: DataAssembly.
|
|
294
|
+
root_node: Parent OPC UA node where the DataAssembly shall be attached.
|
|
295
|
+
"""
|
|
296
|
+
# create new root node for DataAssembly
|
|
297
|
+
if hasattr(data_assembly, "tag_name"):
|
|
298
|
+
tag_name = data_assembly.tag_name
|
|
299
|
+
elif hasattr(data_assembly, "CLASS_DATA_ITEMS"):
|
|
300
|
+
tag_name = data_assembly["TagName"]
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
root_node_data_assembly = self.add_folder_if_not_exists(root_node, tag_name)
|
|
304
|
+
|
|
305
|
+
base_functions = [
|
|
306
|
+
"op_mode",
|
|
307
|
+
"src_mode",
|
|
308
|
+
"os_level",
|
|
309
|
+
"wqc",
|
|
310
|
+
"vqc",
|
|
311
|
+
"service_op_mode",
|
|
312
|
+
"service_src_mode",
|
|
313
|
+
"service_operator_interaction",
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
if hasattr(data_assembly, "_data_items"):
|
|
317
|
+
self._create_opcua_nodes_for_data_items(
|
|
318
|
+
data_assembly._data_items,
|
|
319
|
+
tag_name,
|
|
320
|
+
root_node_data_assembly,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
for base_function_name in base_functions:
|
|
324
|
+
if not hasattr(data_assembly, base_function_name):
|
|
325
|
+
continue
|
|
326
|
+
base_function_root_node = self.add_folder_if_not_exists(
|
|
327
|
+
root_node_data_assembly, base_function_name
|
|
328
|
+
)
|
|
329
|
+
base_function = eval(f"data_assembly.{base_function_name}")
|
|
330
|
+
logger.info(
|
|
331
|
+
f"Creating opcua nodes for {base_function_name}, type{type(base_function)}"
|
|
332
|
+
)
|
|
333
|
+
self._create_opcua_nodes_for_data_items(
|
|
334
|
+
base_function._data_items, # TBD: brutal, mitten rein greifen, am offenen Herzen
|
|
335
|
+
tag_name,
|
|
336
|
+
base_function_root_node,
|
|
337
|
+
)
|
|
338
|
+
except ua.UaStatusCodeError as e:
|
|
339
|
+
msg = f"Error creating DataAssembly {tag_name}: {e}"
|
|
340
|
+
logger.warning(msg)
|
|
341
|
+
|
|
342
|
+
def add_node_or_reference(
|
|
343
|
+
self,
|
|
344
|
+
data_item: DataAssembly,
|
|
345
|
+
parent_node: ua.NodeId,
|
|
346
|
+
node_id: str,
|
|
347
|
+
browse_name: str,
|
|
348
|
+
init_value: Any,
|
|
349
|
+
variant_type: ua.VariantType,
|
|
350
|
+
reference_type: int = ua.ObjectIds.Organizes,
|
|
351
|
+
):
|
|
352
|
+
"""
|
|
353
|
+
Adds a node to the namespace if it does not exist. If the node already exists,
|
|
354
|
+
it creates a reference to the existing node.
|
|
355
|
+
|
|
356
|
+
Arguments:
|
|
357
|
+
parent_node: The parent node under which the new node or reference will be added.
|
|
358
|
+
node_id: The NodeId of the node to add or reference.
|
|
359
|
+
browse_name: The BrowseName of the node to add or reference.
|
|
360
|
+
variant_type: The type of the variable node
|
|
361
|
+
reference_type: The type of reference to create if the node already exists (default is Organizes).
|
|
362
|
+
|
|
363
|
+
Retuns
|
|
364
|
+
The created or referenced node.
|
|
365
|
+
"""
|
|
366
|
+
# Check if the node already exists in the namespace
|
|
367
|
+
existing_node = next(
|
|
368
|
+
(
|
|
369
|
+
child
|
|
370
|
+
for child in parent_node.get_children()
|
|
371
|
+
if child.nodeid.to_string() == node_id
|
|
372
|
+
),
|
|
373
|
+
None,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if existing_node is None and self.node_exists(node_id):
|
|
377
|
+
existing_node = self.opcua_server.get_node(node_id)
|
|
378
|
+
|
|
379
|
+
if existing_node:
|
|
380
|
+
# Node already exists, create a reference to it
|
|
381
|
+
parent_node.add_reference(existing_node.nodeid, reference_type)
|
|
382
|
+
logger.opcua(f"Reference added to existing node: {browse_name}")
|
|
383
|
+
return existing_node
|
|
384
|
+
else:
|
|
385
|
+
# Node does not exist, create it
|
|
386
|
+
opcua_node_obj = parent_node.add_variable(
|
|
387
|
+
node_id,
|
|
388
|
+
browse_name,
|
|
389
|
+
init_value,
|
|
390
|
+
varianttype=variant_type,
|
|
391
|
+
)
|
|
392
|
+
opcua_comm_obj = OPCUACommunicationObject(
|
|
393
|
+
opcua_node_obj, node_id=opcua_node_obj
|
|
394
|
+
)
|
|
395
|
+
data_item.attach_communication_object(opcua_comm_obj)
|
|
396
|
+
if (data_item.access is MTPAccess.ReadWriteAccess) or (
|
|
397
|
+
data_item.access is MTPAccess.WriteAccess
|
|
398
|
+
):
|
|
399
|
+
opcua_node_obj.set_writable(True)
|
|
400
|
+
self.subscription_list.append(opcua_node_obj, data_item.ext_wrt_cb)
|
|
401
|
+
else:
|
|
402
|
+
opcua_node_obj.set_writable(False)
|
|
403
|
+
logger.opcua(f"Node created: {browse_name}")
|
|
404
|
+
return opcua_node_obj
|
|
405
|
+
|
|
406
|
+
def _create_opcua_nodes_for_data_items(
|
|
407
|
+
self, data_items, parent_name: str, root_node
|
|
408
|
+
):
|
|
409
|
+
"""
|
|
410
|
+
Iterate over DataItems of DataAssembly to create corresponding OPC UA nodes.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
data_items: Elements of DataItem that an OPC UA node is to created for.
|
|
414
|
+
parent_name: Name of the DataItem parent, should be TagName of DataAssembly.
|
|
415
|
+
root_node: Parent OPC UA node where the Data is to add to.
|
|
416
|
+
|
|
417
|
+
"""
|
|
418
|
+
logger.debug(f"Creating OPC UA nodes for: {parent_name}")
|
|
419
|
+
|
|
420
|
+
for data_item in data_items.values():
|
|
421
|
+
data_item_node_id = (
|
|
422
|
+
self.get_node_part_ns_index()
|
|
423
|
+
+ self.opcua_options_provider.generate_node_id(
|
|
424
|
+
data_assembly_name=parent_name, data_item_name=data_item.name
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# We attach communication objects to be able to write values on opcua server on DataItems change
|
|
429
|
+
opcua_type = self._map_data_type(data_item.w3c_xml_data_type)
|
|
430
|
+
|
|
431
|
+
_ = self.add_node_or_reference(
|
|
432
|
+
data_item=data_item,
|
|
433
|
+
parent_node=root_node,
|
|
434
|
+
node_id=data_item_node_id,
|
|
435
|
+
browse_name=data_item.name,
|
|
436
|
+
init_value=data_item.init_value,
|
|
437
|
+
variant_type=opcua_type,
|
|
438
|
+
)
|
|
439
|
+
# opcua_node_obj = root_node.add_variable(
|
|
440
|
+
# data_item_node_id,
|
|
441
|
+
# data_item.name,
|
|
442
|
+
# data_item.init_value,
|
|
443
|
+
# varianttype=opcua_type,
|
|
444
|
+
# )
|
|
445
|
+
# opcua_node_obj.set_attribute(
|
|
446
|
+
# ua.AttributeIds.DisplayName,
|
|
447
|
+
# ua.DataValue(ua.LocalizedText(data_item.name)),
|
|
448
|
+
# )
|
|
449
|
+
# opcua_node_obj.set_attribute(
|
|
450
|
+
# ua.AttributeIds.Description,
|
|
451
|
+
# ua.DataValue(ua.LocalizedText(data_item.description)),
|
|
452
|
+
# )
|
|
453
|
+
|
|
454
|
+
logger.opcua(
|
|
455
|
+
f"OPCUA Node: {data_item_node_id}, Name: {data_item.name}, Value: {data_item.init_value}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def _map_data_type(w3c_xml_data_type):
|
|
460
|
+
"""
|
|
461
|
+
Translate a python data type to a suitable OPC UA data type.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
w3c_xml_data_type: W3cXmlDataType.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
OPC UA data type
|
|
468
|
+
"""
|
|
469
|
+
if not isinstance(w3c_xml_data_type, W3cXmlDataTypes):
|
|
470
|
+
raise TypeError(
|
|
471
|
+
f"Invalid argument: {w3c_xml_data_type} is not a valid W3cXmlDataType"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
opcua_variant = w3c_xml_data_type.opcua_variant
|
|
475
|
+
if opcua_variant is None:
|
|
476
|
+
raise TypeError(
|
|
477
|
+
f"Data type {w3c_xml_data_type} is not supported for OPC UA mapping"
|
|
478
|
+
)
|
|
479
|
+
return opcua_variant
|
|
480
|
+
|
|
481
|
+
def _start_subscription(self):
|
|
482
|
+
"""
|
|
483
|
+
Subscribes to defined OPC UA nodes.
|
|
484
|
+
:return:
|
|
485
|
+
"""
|
|
486
|
+
handler = Marshalling()
|
|
487
|
+
handler.import_subscription_list(self.subscription_list)
|
|
488
|
+
node_ids = self.subscription_list.get_nodeid_list()
|
|
489
|
+
if not node_ids:
|
|
490
|
+
return
|
|
491
|
+
sub = self.opcua_server.create_subscription(500, handler)
|
|
492
|
+
sub.subscribe_data_change(node_ids)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Subscription list that contains all OPC UA nodes that a PEA must subscribe to."""
|
|
2
|
+
|
|
3
|
+
__copyright__ = (
|
|
4
|
+
"Copyright (c) 2026 Dresden University of Technology, Process-to-Order Group"
|
|
5
|
+
)
|
|
6
|
+
__license__ = "MIT"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SubscriptionList:
|
|
10
|
+
"""A class to manage subscriptions to OPC UA nodes.
|
|
11
|
+
|
|
12
|
+
This class maintains a list of OPC UA nodes that a PEA (Process Execution Agent)
|
|
13
|
+
must subscribe to. It provides methods to add subscriptions, retrieve node IDs,
|
|
14
|
+
and obtain callback functions for value changes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
"""Initialize instance of Subscription List with empty list."""
|
|
19
|
+
self.sub_list = {}
|
|
20
|
+
|
|
21
|
+
def append(self, node_id, cb_value_change):
|
|
22
|
+
"""Add a subscription entity.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
node_id: OPC UA node.
|
|
26
|
+
cb_value_change: Callback function for a value change.
|
|
27
|
+
"""
|
|
28
|
+
identifier = node_id.nodeid.Identifier
|
|
29
|
+
self.sub_list[identifier] = {"node_id": node_id, "callback": cb_value_change}
|
|
30
|
+
|
|
31
|
+
def get_nodeid_list(self):
|
|
32
|
+
"""Extract a list of node ids in the subscription list.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
List of node ids.
|
|
36
|
+
"""
|
|
37
|
+
if len(self.sub_list) == 0:
|
|
38
|
+
return None
|
|
39
|
+
else:
|
|
40
|
+
node_id_list = []
|
|
41
|
+
for node_id in self.sub_list.values():
|
|
42
|
+
node_id_list.append(node_id["node_id"])
|
|
43
|
+
return node_id_list
|
|
44
|
+
|
|
45
|
+
def get_callback(self, node_id):
|
|
46
|
+
"""Get a callback function for a specific OPC UA node.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
node_id: OPC UA node id
|
|
50
|
+
"""
|
|
51
|
+
identifier = node_id.nodeid.Identifier
|
|
52
|
+
if identifier in self.sub_list.keys():
|
|
53
|
+
return self.sub_list[identifier]["callback"]
|
|
54
|
+
else:
|
|
55
|
+
return None
|
mtppy/pea/pea_test.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tests for PEA component registration."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: S101
|
|
4
|
+
|
|
5
|
+
__copyright__ = (
|
|
6
|
+
"Copyright (c) 2026 Dresden University of Technology, Process-to-Order Group"
|
|
7
|
+
)
|
|
8
|
+
__license__ = "MIT"
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from mtppy.part3.data_assembly_set.indicator_elements.views.bin_view import BinView
|
|
13
|
+
from mtppy.part6.alarm_set.alarm import Alarm
|
|
14
|
+
from mtppy.pea.pea import PEA
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_alarm(tag_name: str = "LevelHigh.Alarm") -> Alarm:
|
|
18
|
+
"""Build an alarm suitable for PEA registration tests."""
|
|
19
|
+
trigger_assembly = BinView("LevelHigh")
|
|
20
|
+
return Alarm(
|
|
21
|
+
tag_name=tag_name,
|
|
22
|
+
data_assembly=trigger_assembly,
|
|
23
|
+
trigger=trigger_assembly.get_item(BinView.V_),
|
|
24
|
+
trigger_value=True,
|
|
25
|
+
message="Level is too high.",
|
|
26
|
+
ack_required=True,
|
|
27
|
+
severity=500,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_add_alarm_registers_alarm_by_tag_name() -> None:
|
|
32
|
+
"""PEA exposes registered alarms through its alarm dictionary."""
|
|
33
|
+
pea = PEA()
|
|
34
|
+
alarm = build_alarm()
|
|
35
|
+
|
|
36
|
+
pea.add_alarm(alarm)
|
|
37
|
+
|
|
38
|
+
assert pea.alarms == {alarm.tag_name: alarm}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_add_alarm_rejects_wrong_type() -> None:
|
|
42
|
+
"""PEA rejects arbitrary objects in the alarm registry."""
|
|
43
|
+
pea = PEA()
|
|
44
|
+
|
|
45
|
+
with pytest.raises(TypeError, match="Alarm"):
|
|
46
|
+
pea.add_alarm(object()) # type: ignore[arg-type]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_add_alarm_rejects_duplicate_tag_name() -> None:
|
|
50
|
+
"""A later alarm must not silently replace an existing alarm."""
|
|
51
|
+
pea = PEA()
|
|
52
|
+
first = build_alarm()
|
|
53
|
+
duplicate = build_alarm()
|
|
54
|
+
pea.add_alarm(first)
|
|
55
|
+
|
|
56
|
+
with pytest.raises(ValueError, match="already registered"):
|
|
57
|
+
pea.add_alarm(duplicate)
|
|
58
|
+
|
|
59
|
+
assert pea.alarms[first.tag_name] is first
|