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

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

## 156 statements

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"""

4 Communication protocol implementing an OPC UA connection.

5 This protocol is used to interface with the "Supercube" PLC from Siemens.

6"""

7

8 import errno

9 import logging

10 from collections.abc import Iterable as IterableBase

11 from concurrent.futures import CancelledError, TimeoutError

12 from functools import wraps

13 from logging import Logger

14 from socket import gaierror

15 from time import sleep

16 from typing import Iterable, Union, Optional

17

18 from opcua import Client, Node, Subscription

19 from opcua.ua import NodeId, DataValue, UaError

20 from opcua.ua.uaerrors import BadSubscriptionIdInvalid

21

22 from .base import CommunicationProtocol

23 from ..configuration import configdataclass

24

25

26 class 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.

30

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 """

36

37 def datachange_notification(self, node, val, data):

38 logging.getLogger(__name__).debug(

39 f"OPCUA Datachange event: {node} to value {val}"

40 )

41

42 def event_notification(self, event):

43 logging.getLogger(__name__).debug(f"OPCUA Event: {event}")

44

45

46@configdataclass

47 class OpcUaCommunicationConfig:

48 """

49 Configuration dataclass for OPC UA Communciation.

50 """

51

52 #: Hostname or IP-Address of the OPC UA server.

53 host: str

54

55 #: Endpoint of the OPC server, this is a path like 'OPCUA/SimulationServer'

56 endpoint_name: str

57

58 #: Port of the OPC UA server to connect to.

59 port: int = 4840

60

61 #: object to use for handling subscriptions.

62 sub_handler: OpcUaSubHandler = OpcUaSubHandler()

63

64 #: Update period for generating datachange events in OPC UA [milli seconds]

65 update_period: int = 500

66

67 #: Wait time between re-trying calls on underlying OPC UA client timeout error

68 wait_timeout_retry_sec: Union[int, float] = 1

69

70 #: Maximal number of call re-tries on underlying OPC UA client timeout error

71 max_timeout_retry_nr: int = 5

72

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 )

87

88

89 class OpcUaCommunicationIOError(IOError):

90 """OPC-UA communication I/O error."""

91

92

93 class OpcUaCommunicationTimeoutError(OpcUaCommunicationIOError):

94 """OPC-UA communication timeout error."""

95

96

97 def _log_error(

98 log: Logger, e: Exception, msg_prefix: str = "", add_traceback: bool = False,

99) -> None:

100 """

101 Log error message.

102

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}")

113

114

115#: current number of reopen tries on OPC UA connection error

116 _n_timeout_retry = 0

117

118

119 def _wrap_ua_error(method):

120 """

121 Wrap any `UaError` raised from a `OpcUaCommunication` method into

122 `OpcUaCommunicationIOError`; additionally, log source error.

123

124 :param method: `OpcUaCommunication` instance method to wrap

125 :return: Whatever `method` returns

126 """

127

128 @wraps(method)

129 def wrapper(self, *args, **kwargs):

130

131 try:

132

133 result = method(self, *args, **kwargs)

134

135 except UaError as e:

136

137 _log_error(self.logger, e, "OPC UA client runtime error: ", True)

138 raise OpcUaCommunicationIOError from e

139

140 except gaierror as e:

141

142 _log_error(self.logger, e, "Socket address error: ", True)

143 raise OpcUaCommunicationIOError from e

144

145 except OSError as e:

146

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

153

154 except CancelledError as e:

155

156 _log_error(self.logger, e, "OPC UA client thread cancelled error: ", True)

157 raise OpcUaCommunicationIOError from e

158

159 except TimeoutError as e:

160

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

168

169 self.logger.info(

170 f"OPC UA client retry #{_n_timeout_retry}/#{_max_try_reopen}:"

171 f" {method}"

172 )

173

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

178

179 else:

180 # failure => reset global counter

181 _n_timeout_retry = 0

182 # raise from original timeout error

183 raise OpcUaCommunicationTimeoutError from e

184

185 return result

186

187 return wrapper

188

189

190 def _require_ua_opened(method):

191 """

192 Check if `opcua.client.ua_client.UaClient` socket is opened and raise an

193 `OpcUaCommunicationIOError` if not.

194

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 ...`.

198

199 :param method: `OpcUaCommunication` instance method to wrap

200 :return: Whatever `method` returns

201 """

202

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)

212

213 return wrapper

214

215

216 class OpcUaCommunication(CommunicationProtocol):

217 """

218 Communication protocol implementing an OPC UA connection.

219 Makes use of the package python-opcua.

220 """

221

222 def __init__(self, config) -> None:

223 """

224 Constructor for OpcUaCommunication.

225

226 :param config: is the configuration dictionary.

227 """

228

229 super().__init__(config)

230

231 self.logger = logging.getLogger(__name__)

232

233 conf = self.config

234 url = f"opc.tcp://{conf.host}:{conf.port}/{conf.endpoint_name}"

235

236 self.logger.info(f"Create OPC UA client to URL: {url}")

237

238 self._client = Client(url)

239

240 # the objects node exists on every OPC UA server and are root for all objects.

241 self._objects_node: Optional[Node] = None

242

243 # subscription handler

244 self._sub_handler = self.config.sub_handler

245

246 # subscription object

247 self._subscription: Optional[Subscription] = None

248

249 @staticmethod

250 def config_cls():

251 return OpcUaCommunicationConfig

252

253 @_wrap_ua_error

254 def open(self) -> None:

255 """

256 Open the communication to the OPC UA server.

257

258 :raises OpcUaCommunicationIOError: when communication port cannot be opened.

259 """

260

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 )

272

273 @property

274 def is_open(self) -> bool:

275 """

276 Flag indicating if the communication port is open.

277

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

291

292 @_wrap_ua_error

293 def close(self) -> None:

294 """

295 Close the connection to the OPC UA server.

296 """

297

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()

309

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.

315

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 """

322

323 with self.access_lock:

324 return self._client.get_node(

325 NodeId(identifier=node_id, namespaceidx=ns_index)

326 ).get_value()

327

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``.

333

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 """

340

341 with self.access_lock:

342 self._client.get_node(

343 NodeId(identifier=node_id, namespaceidx=ns_index)

344 ).set_value(DataValue(value))

345

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.

353

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 """

360

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)

365

366 ids: Iterable[object] = (

367 node_id

368 if not isinstance(node_id, str) and isinstance(node_id, IterableBase)

369 else (node_id,)

370 )

371

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 )

379

380 with self.access_lock:

381 self._subscription.subscribe_data_change(nodes)