Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\comm\opc.py : 38%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Copyright (c) 2019-2020 ETH Zurich, SIS ID and HVL D-ITET
2#
3"""
4Communication protocol implementing an OPC UA connection.
5This protocol is used to interface with the "Supercube" PLC from Siemens.
6"""
8import errno
9import logging
10from collections.abc import Iterable as IterableBase
11from concurrent.futures import CancelledError, TimeoutError
12from functools import wraps
13from logging import Logger
14from socket import gaierror
15from time import sleep
16from typing import Iterable, Union, Optional
18from opcua import Client, Node, Subscription
19from opcua.ua import NodeId, DataValue, UaError
20from opcua.ua.uaerrors import BadSubscriptionIdInvalid
22from .base import CommunicationProtocol
23from ..configuration import configdataclass
26class OpcUaSubHandler:
27 """
28 Base class for subscription handling of OPC events and data change events.
29 Override methods from this class to add own handling capabilities.
31 To receive events from server for a subscription
32 data_change and event methods are called directly from receiving thread.
33 Do not do expensive, slow or network operation there. Create another
34 thread if you need to do such a thing.
35 """
37 def datachange_notification(self, node, val, data):
38 logging.getLogger(__name__).debug(
39 f"OPCUA Datachange event: {node} to value {val}"
40 )
42 def event_notification(self, event):
43 logging.getLogger(__name__).debug(f"OPCUA Event: {event}")
46@configdataclass
47class OpcUaCommunicationConfig:
48 """
49 Configuration dataclass for OPC UA Communciation.
50 """
52 #: Hostname or IP-Address of the OPC UA server.
53 host: str
55 #: Endpoint of the OPC server, this is a path like 'OPCUA/SimulationServer'
56 endpoint_name: str
58 #: Port of the OPC UA server to connect to.
59 port: int = 4840
61 #: object to use for handling subscriptions.
62 sub_handler: OpcUaSubHandler = OpcUaSubHandler()
64 #: Update period for generating datachange events in OPC UA [milli seconds]
65 update_period: int = 500
67 #: Wait time between re-trying calls on underlying OPC UA client timeout error
68 wait_timeout_retry_sec: Union[int, float] = 1
70 #: Maximal number of call re-tries on underlying OPC UA client timeout error
71 max_timeout_retry_nr: int = 5
73 def clean_values(self):
74 if self.update_period <= 0:
75 raise ValueError(
76 "Update period for generating datachange events (msec) needs to be "
77 "a positive integer."
78 )
79 if self.wait_timeout_retry_sec <= 0:
80 raise ValueError(
81 "Re-try wait time (sec) on timeout needs to be a positive number."
82 )
83 if self.max_timeout_retry_nr < 0:
84 raise ValueError(
85 "Maximal re-tries count on timeout needs to be non-negative integer."
86 )
89class OpcUaCommunicationIOError(IOError):
90 """OPC-UA communication I/O error."""
93class OpcUaCommunicationTimeoutError(OpcUaCommunicationIOError):
94 """OPC-UA communication timeout error."""
97def _log_error(
98 log: Logger, e: Exception, msg_prefix: str = "", add_traceback: bool = False,
99) -> None:
100 """
101 Log error message.
103 :param log: logger to use
104 :param e: error to log
105 :param msg_prefix: error message prefix added before error class name and message
106 :param add_traceback: if to append last error traceback to the log message
107 """
108 msg_suffix = ""
109 if add_traceback:
110 import traceback
111 msg_suffix = f"\n{traceback.format_exc()}"
112 log.error(f"{msg_prefix}{e.__class__.__name__}: {str(e)}{msg_suffix}")
115#: current number of reopen tries on OPC UA connection error
116_n_timeout_retry = 0
119def _wrap_ua_error(method):
120 """
121 Wrap any `UaError` raised from a `OpcUaCommunication` method into
122 `OpcUaCommunicationIOError`; additionally, log source error.
124 :param method: `OpcUaCommunication` instance method to wrap
125 :return: Whatever `method` returns
126 """
128 @wraps(method)
129 def wrapper(self, *args, **kwargs):
131 try:
133 result = method(self, *args, **kwargs)
135 except UaError as e:
137 _log_error(self.logger, e, "OPC UA client runtime error: ", True)
138 raise OpcUaCommunicationIOError from e
140 except gaierror as e:
142 _log_error(self.logger, e, "Socket address error: ", True)
143 raise OpcUaCommunicationIOError from e
145 except OSError as e:
147 if e.errno == errno.EBADF:
148 log_prefix = "OPC UA client socket error: "
149 else:
150 log_prefix = "OPC UA client OS error: "
151 _log_error(self.logger, e, log_prefix, True)
152 raise OpcUaCommunicationIOError from e
154 except CancelledError as e:
156 _log_error(self.logger, e, "OPC UA client thread cancelled error: ", True)
157 raise OpcUaCommunicationIOError from e
159 except TimeoutError as e:
161 _log_error(self.logger, e, "OPC UA client thread timeout error: ", True)
162 # try close, re-open and re-call
163 global _n_timeout_retry
164 _max_try_reopen = self.config.max_timeout_retry_nr
165 if _n_timeout_retry < _max_try_reopen:
166 sleep(self.config.wait_timeout_retry_sec)
167 _n_timeout_retry += 1
169 self.logger.info(
170 f"OPC UA client retry #{_n_timeout_retry}/#{_max_try_reopen}:"
171 f" {method}"
172 )
174 # note: nested re-tries use the global counter to stop on max limit
175 result = wrapper(self, *args, **kwargs)
176 # success => reset global counter
177 _n_timeout_retry = 0
179 else:
180 # failure => reset global counter
181 _n_timeout_retry = 0
182 # raise from original timeout error
183 raise OpcUaCommunicationTimeoutError from e
185 return result
187 return wrapper
190def _require_ua_opened(method):
191 """
192 Check if `opcua.client.ua_client.UaClient` socket is opened and raise an
193 `OpcUaCommunicationIOError` if not.
195 NOTE: this checks should be implemented downstream in
196 `opcua.client.ua_client.UaClient`; currently you get `AttributeError: 'NoneType'
197 object has no attribute ...`.
199 :param method: `OpcUaCommunication` instance method to wrap
200 :return: Whatever `method` returns
201 """
203 @wraps(method)
204 def wrapper(self, *args, **kwargs):
205 # BLAH: this checks should be implemented downstream in
206 # `opcua.client.ua_client.UaClient`
207 if self._client.uaclient._uasocket is None:
208 err_msg = f"Client's socket is not set in {str(self)}. Was it opened?"
209 self.logger.error(err_msg)
210 raise OpcUaCommunicationIOError(err_msg)
211 return method(self, *args, **kwargs)
213 return wrapper
216class OpcUaCommunication(CommunicationProtocol):
217 """
218 Communication protocol implementing an OPC UA connection.
219 Makes use of the package python-opcua.
220 """
222 def __init__(self, config) -> None:
223 """
224 Constructor for OpcUaCommunication.
226 :param config: is the configuration dictionary.
227 """
229 super().__init__(config)
231 self.logger = logging.getLogger(__name__)
233 conf = self.config
234 url = f"opc.tcp://{conf.host}:{conf.port}/{conf.endpoint_name}"
236 self.logger.info(f"Create OPC UA client to URL: {url}")
238 self._client = Client(url)
240 # the objects node exists on every OPC UA server and are root for all objects.
241 self._objects_node: Optional[Node] = None
243 # subscription handler
244 self._sub_handler = self.config.sub_handler
246 # subscription object
247 self._subscription: Optional[Subscription] = None
249 @staticmethod
250 def config_cls():
251 return OpcUaCommunicationConfig
253 @_wrap_ua_error
254 def open(self) -> None:
255 """
256 Open the communication to the OPC UA server.
258 :raises OpcUaCommunicationIOError: when communication port cannot be opened.
259 """
261 self.logger.info("Open connection to OPC server.")
262 with self.access_lock:
263 self._client.connect()
264 # in example from opcua, load_type_definitions() is called after connect(
265 # ). However, this raises ValueError when connecting to Siemens S7,
266 # and no problems are detected omitting this call.
267 # self._client.load_type_definitions()
268 self._objects_node = self._client.get_objects_node()
269 self._subscription = self._client.create_subscription(
270 self.config.update_period, self._sub_handler
271 )
273 @property
274 def is_open(self) -> bool:
275 """
276 Flag indicating if the communication port is open.
278 :return: `True` if the port is open, otherwise `False`
279 """
280 open_called = self._objects_node or self._subscription
281 if open_called:
282 try:
283 self._client.send_hello()
284 return True
285 except UaError as e:
286 self.logger.info(f"Sending hello returned UA error: {str(e)}")
287 # try cleanup in case connection was opened before but now is lost
288 if open_called:
289 self.close()
290 return False
292 @_wrap_ua_error
293 def close(self) -> None:
294 """
295 Close the connection to the OPC UA server.
296 """
298 self.logger.info("Close connection to OPC server.")
299 with self.access_lock:
300 if self._subscription:
301 try:
302 self._subscription.delete()
303 except BadSubscriptionIdInvalid:
304 pass
305 self._subscription = None
306 if self._objects_node:
307 self._objects_node = None
308 self._client.disconnect()
310 @_require_ua_opened
311 @_wrap_ua_error
312 def read(self, node_id, ns_index):
313 """
314 Read a value from a node with id and namespace index.
316 :param node_id: the ID of the node to read the value from
317 :param ns_index: the namespace index of the node
318 :return: the value of the node object.
319 :raises OpcUaCommunicationIOError: when protocol was not opened or can't
320 communicate with a OPC UA server
321 """
323 with self.access_lock:
324 return self._client.get_node(
325 NodeId(identifier=node_id, namespaceidx=ns_index)
326 ).get_value()
328 @_require_ua_opened
329 @_wrap_ua_error
330 def write(self, node_id, ns_index, value) -> None:
331 """
332 Write a value to a node with name ``name``.
334 :param node_id: the id of the node to write the value to.
335 :param ns_index: the namespace index of the node.
336 :param value: the value to write.
337 :raises OpcUaCommunicationIOError: when protocol was not opened or can't
338 communicate with a OPC UA server
339 """
341 with self.access_lock:
342 self._client.get_node(
343 NodeId(identifier=node_id, namespaceidx=ns_index)
344 ).set_value(DataValue(value))
346 @_require_ua_opened
347 @_wrap_ua_error
348 def init_monitored_nodes(
349 self, node_id: Union[object, Iterable], ns_index: int
350 ) -> None:
351 """
352 Initialize monitored nodes.
354 :param node_id: one or more strings of node IDs; node IDs are always casted
355 via `str()` method here, hence do not have to be strictly string objects.
356 :param ns_index: the namespace index the nodes belong to.
357 :raises OpcUaCommunicationIOError: when protocol was not opened or can't
358 communicate with a OPC UA server
359 """
361 if not self._subscription:
362 err_msg = f"Missing subscription in {str(self)}. Was it opened?"
363 self.logger.error(err_msg)
364 raise OpcUaCommunicationIOError(err_msg)
366 ids: Iterable[object] = (
367 node_id
368 if not isinstance(node_id, str) and isinstance(node_id, IterableBase)
369 else (node_id,)
370 )
372 nodes = []
373 for id_ in ids:
374 nodes.append(
375 self._client.get_node(
376 NodeId(identifier=str(id_), namespaceidx=ns_index)
377 )
378 )
380 with self.access_lock:
381 self._subscription.subscribe_data_change(nodes)