Hide keyboard shortcuts

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

7 

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 

17 

18from opcua import Client, Node, Subscription 

19from opcua.ua import NodeId, DataValue, UaError 

20from opcua.ua.uaerrors import BadSubscriptionIdInvalid 

21 

22from .base import CommunicationProtocol 

23from ..configuration import configdataclass 

24 

25 

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. 

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 

47class 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 

89class OpcUaCommunicationIOError(IOError): 

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

91 

92 

93class OpcUaCommunicationTimeoutError(OpcUaCommunicationIOError): 

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

95 

96 

97def _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 

119def _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 

190def _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 

216class 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)