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

4Base classes for the Supercube device. 

5""" 

6 

7import logging 

8from collections import deque 

9from datetime import datetime 

10from itertools import cycle 

11from time import sleep 

12from typing import List, Sequence 

13 

14from opcua import Node 

15 

16from . import constants 

17from ..base import SingleCommDevice 

18from ..utils import Poller 

19from ...comm import ( 

20 OpcUaCommunication, 

21 OpcUaCommunicationConfig, 

22 OpcUaSubHandler, 

23) 

24from ...configuration import configdataclass 

25from ...utils.typing import Number 

26 

27 

28class SupercubeEarthingStickOperationError(Exception): 

29 pass 

30 

31 

32class SupercubeSubscriptionHandler(OpcUaSubHandler): 

33 """ 

34 OPC Subscription handler for datachange events and normal events specifically 

35 implemented for the Supercube devices. 

36 """ 

37 

38 def datachange_notification(self, node: Node, val, data): 

39 """ 

40 In addition to the standard operation (debug logging entry of the datachange), 

41 alarms are logged at INFO level using the alarm text. 

42 

43 :param node: the node object that triggered the datachange event 

44 :param val: the new value 

45 :param data: 

46 """ 

47 

48 super().datachange_notification(node, val, data) 

49 

50 # assume an alarm datachange 

51 try: 

52 alarm_number = constants.Alarms(node.nodeid.Identifier).number 

53 alarm_text = constants.AlarmText.get(alarm_number) 

54 alarm_log_prefix = "Coming" if val else "Going" 

55 logging.getLogger(__name__).info( 

56 f"{alarm_log_prefix} Alarm {alarm_number}: {str(alarm_text)}" 

57 ) 

58 return 

59 except ValueError: 

60 # not any of Alarms node IDs 

61 pass 

62 

63 id_ = node.nodeid.Identifier 

64 

65 # assume a status datachange 

66 if id_ == str(constants.Safety.status): 

67 new_status = str(constants.SafetyStatus(val)) 

68 logging.getLogger(__name__).info(f"Safety: {new_status}") 

69 return 

70 

71 # assume an earthing stick status datachange 

72 if id_ in constants.EarthingStick.statuses(): 

73 new_status = str(constants.EarthingStickStatus(val)) 

74 logging.getLogger(__name__).info( 

75 f"Earthing {constants.EarthingStick(id_).number}: {new_status}" 

76 ) 

77 return 

78 

79 

80@configdataclass 

81class SupercubeConfiguration: 

82 """ 

83 Configuration dataclass for the Supercube devices. 

84 """ 

85 

86 #: Namespace of the OPC variables, typically this is 3 (coming from Siemens) 

87 namespace_index: int = 3 

88 

89 polling_delay_sec: Number = 5.0 

90 polling_interval_sec: Number = 1.0 

91 

92 def clean_values(self): 

93 if self.namespace_index < 0: 

94 raise ValueError( 

95 "Index of the OPC variables namespace needs to be a positive integer." 

96 ) 

97 if self.polling_interval_sec <= 0: 

98 raise ValueError("Polling interval needs to be positive.") 

99 if self.polling_delay_sec < 0: 

100 raise ValueError("Polling delay needs to be not negative.") 

101 

102 

103@configdataclass 

104class SupercubeOpcUaCommunicationConfig(OpcUaCommunicationConfig): 

105 """ 

106 Communication protocol configuration for OPC UA, specifications for the Supercube 

107 devices. 

108 """ 

109 

110 #: Subscription handler for data change events 

111 sub_handler: OpcUaSubHandler = SupercubeSubscriptionHandler() 

112 

113 

114class SupercubeOpcUaCommunication(OpcUaCommunication): 

115 """ 

116 Communication protocol specification for Supercube devices. 

117 """ 

118 

119 @staticmethod 

120 def config_cls(): 

121 return SupercubeOpcUaCommunicationConfig 

122 

123 

124class SupercubeBase(SingleCommDevice): 

125 """ 

126 Base class for Supercube variants. 

127 """ 

128 

129 def __init__(self, com, dev_config=None): 

130 """ 

131 Constructor for Supercube base class. 

132 

133 :param com: the communication protocol or its configuration 

134 :param dev_config: the device configuration 

135 """ 

136 

137 super().__init__(com, dev_config) 

138 

139 self.status_poller = Poller( 

140 self._spoll_handler, 

141 polling_delay_sec=self.config.polling_delay_sec, 

142 polling_interval_sec=self.config.polling_interval_sec, 

143 ) 

144 self.toggle = cycle([False, True]) 

145 self.logger = logging.getLogger(__name__) 

146 self.message_len = len(constants.MessageBoard) 

147 self.status_board = [""] * self.message_len 

148 self.message_board = deque([""] * self.message_len, maxlen=self.message_len) 

149 

150 @staticmethod 

151 def default_com_cls(): 

152 return SupercubeOpcUaCommunication 

153 

154 def start(self) -> None: 

155 """ 

156 Starts the device. Sets the root node for all OPC read and write commands to 

157 the Siemens PLC object node which holds all our relevant objects and variables. 

158 """ 

159 

160 self.logger.info("Starting Supercube Base device") 

161 super().start() 

162 

163 self.logger.debug("Add monitoring nodes") 

164 self.com.init_monitored_nodes( 

165 map( # type: ignore 

166 str, constants.GeneralSockets 

167 ), 

168 self.config.namespace_index, 

169 ) 

170 self.com.init_monitored_nodes( 

171 map( # type: ignore 

172 str, constants.GeneralSupport 

173 ), 

174 self.config.namespace_index, 

175 ) 

176 self.com.init_monitored_nodes( 

177 map( # type: ignore 

178 str, constants.Safety 

179 ), 

180 self.config.namespace_index, 

181 ) 

182 self.com.init_monitored_nodes( 

183 str(constants.Errors.message), self.config.namespace_index 

184 ) 

185 self.com.init_monitored_nodes( 

186 str(constants.Errors.warning), self.config.namespace_index 

187 ) 

188 self.com.init_monitored_nodes( 

189 str(constants.Errors.stop), self.config.namespace_index 

190 ) 

191 self.com.init_monitored_nodes( 

192 map(str, constants.Alarms), self.config.namespace_index 

193 ) 

194 self.com.init_monitored_nodes( 

195 map( # type: ignore 

196 str, constants.EarthingStick 

197 ), 

198 self.config.namespace_index, 

199 ) 

200 

201 self.set_remote_control(True) 

202 self.logger.debug("Finished starting") 

203 

204 def stop(self) -> None: 

205 """ 

206 Stop the Supercube device. Deactivates the remote control and closes the 

207 communication protocol. 

208 """ 

209 try: 

210 self.set_remote_control(False) 

211 finally: 

212 super().stop() 

213 

214 def _spoll_handler(self) -> None: 

215 """ 

216 Supercube poller handler; change one byte on a SuperCube. 

217 """ 

218 self.write(constants.OpcControl.live, next(self.toggle)) 

219 

220 @staticmethod 

221 def config_cls(): 

222 return SupercubeConfiguration 

223 

224 def read(self, node_id: str): 

225 """ 

226 Local wrapper for the OPC UA communication protocol read method. 

227 

228 :param node_id: the id of the node to read. 

229 :return: the value of the variable 

230 """ 

231 

232 self.logger.debug(f"Read from node ID {node_id} ...") 

233 result = self.com.read(str(node_id), self.config.namespace_index) 

234 self.logger.debug(f"Read from node ID {node_id}: {result}") 

235 return result 

236 

237 def write(self, node_id, value) -> None: 

238 """ 

239 Local wrapper for the OPC UA communication protocol write method. 

240 

241 :param node_id: the id of the node to read 

242 :param value: the value to write to the variable 

243 """ 

244 self.logger.debug(f"Write to node ID {node_id}: {value}") 

245 self.com.write(str(node_id), self.config.namespace_index, value) 

246 

247 def set_remote_control(self, state: bool) -> None: 

248 """ 

249 Enable or disable remote control for the Supercube. This will effectively 

250 display a message on the touchscreen HMI. 

251 

252 :param state: desired remote control state 

253 """ 

254 can_write = False 

255 try: 

256 self.write(constants.OpcControl.active, bool(state)) 

257 can_write = True 

258 finally: 

259 if state: 

260 if not can_write: 

261 self.logger.warning("Remote control cannot be enabled") 

262 else: 

263 was_not_polling = self.status_poller.start_polling() 

264 if not was_not_polling: 

265 self.logger.warning("Remote control already enabled") 

266 else: 

267 was_polling = self.status_poller.stop_polling() 

268 self.status_poller.wait_for_polling_result() 

269 if not was_polling: 

270 self.logger.warning("Remote control already disabled") 

271 

272 def get_support_input(self, port: int, contact: int) -> bool: 

273 """ 

274 Get the state of a support socket input. 

275 

276 :param port: is the socket number (1..6) 

277 :param contact: is the contact on the socket (1..2) 

278 :return: digital input read state 

279 :raises ValueError: when port or contact number is not valid 

280 """ 

281 

282 return bool(self.read(constants.GeneralSupport.input(port, contact))) 

283 

284 def get_support_output(self, port: int, contact: int) -> bool: 

285 """ 

286 Get the state of a support socket output. 

287 

288 :param port: is the socket number (1..6) 

289 :param contact: is the contact on the socket (1..2) 

290 :return: digital output read state 

291 :raises ValueError: when port or contact number is not valid 

292 """ 

293 

294 return bool(self.read(constants.GeneralSupport.output(port, contact))) 

295 

296 def set_support_output(self, port: int, contact: int, state: bool) -> None: 

297 """ 

298 Set the state of a support output socket. 

299 

300 :param port: is the socket number (1..6) 

301 :param contact: is the contact on the socket (1..2) 

302 :param state: is the desired state of the support output 

303 :raises ValueError: when port or contact number is not valid 

304 """ 

305 

306 self.write(constants.GeneralSupport.output(port, contact), bool(state)) 

307 

308 def set_support_output_impulse( 

309 self, port: int, contact: int, duration: float = 0.2, pos_pulse: bool = True 

310 ) -> None: 

311 """ 

312 Issue an impulse of a certain duration on a support output contact. The polarity 

313 of the pulse (On-wait-Off or Off-wait-On) is specified by the pos_pulse 

314 argument. 

315 

316 This function is blocking. 

317 

318 :param port: is the socket number (1..6) 

319 :param contact: is the contact on the socket (1..2) 

320 :param duration: is the length of the impulse in seconds 

321 :param pos_pulse: is True, if the pulse shall be HIGH, False if it shall be LOW 

322 :raises ValueError: when port or contact number is not valid 

323 """ 

324 

325 self.set_support_output(port, contact, pos_pulse) 

326 sleep(duration) 

327 self.set_support_output(port, contact, not pos_pulse) 

328 

329 def get_t13_socket(self, port: int) -> bool: 

330 """ 

331 Read the state of a SEV T13 power socket. 

332 

333 :param port: is the socket number, one of `constants.T13_SOCKET_PORTS` 

334 :return: on-state of the power socket 

335 :raises ValueError: when port is not valid 

336 """ 

337 

338 if port not in constants.T13_SOCKET_PORTS: 

339 raise ValueError(f"port not in {constants.T13_SOCKET_PORTS}: {port}") 

340 

341 return bool(self.read(getattr(constants.GeneralSockets, f"t13_{port}"))) 

342 

343 def set_t13_socket(self, port: int, state: bool) -> None: 

344 """ 

345 Set the state of a SEV T13 power socket. 

346 

347 :param port: is the socket number, one of `constants.T13_SOCKET_PORTS` 

348 :param state: is the desired on-state of the socket 

349 :raises ValueError: when port is not valid or state is not of type bool 

350 """ 

351 

352 if not isinstance(state, bool): 

353 raise ValueError(f"state is not <bool>: {state}") 

354 

355 if port not in constants.T13_SOCKET_PORTS: 

356 raise ValueError(f"port not in {constants.T13_SOCKET_PORTS}: {port}") 

357 

358 self.write(getattr(constants.GeneralSockets, f"t13_{port}"), state) 

359 

360 def get_cee16_socket(self) -> bool: 

361 """ 

362 Read the on-state of the IEC CEE16 three-phase power socket. 

363 

364 :return: the on-state of the CEE16 power socket 

365 """ 

366 

367 return bool(self.read(constants.GeneralSockets.cee16)) 

368 

369 def set_cee16_socket(self, state: bool) -> None: 

370 """ 

371 Switch the IEC CEE16 three-phase power socket on or off. 

372 

373 :param state: desired on-state of the power socket 

374 :raises ValueError: if state is not of type bool 

375 """ 

376 

377 if not isinstance(state, bool): 

378 raise ValueError(f"state is not <bool>: {state}") 

379 

380 self.write(constants.GeneralSockets.cee16, state) 

381 

382 def get_status(self) -> constants.SafetyStatus: 

383 """ 

384 Get the safety circuit status of the Supercube. 

385 :return: the safety status of the supercube's state machine. 

386 """ 

387 

388 return constants.SafetyStatus(self.read(constants.Safety.status)) 

389 

390 def ready(self, state: bool) -> None: 

391 """ 

392 Set ready state. Ready means locket safety circuit, red lamps, but high voltage 

393 still off. 

394 

395 :param state: set ready state 

396 """ 

397 

398 self.write(constants.Safety.switch_to_ready, state) 

399 

400 def operate(self, state: bool) -> None: 

401 """ 

402 Set operate state. If the state is RedReady, this will turn on the high 

403 voltage and close the safety switches. 

404 

405 :param state: set operate state 

406 """ 

407 

408 self.write(constants.Safety.switch_to_operate, state) 

409 

410 def get_measurement_ratio(self, channel: int) -> float: 

411 """ 

412 Get the set measurement ratio of an AC/DC analog input channel. Every input 

413 channel has a divider ratio assigned during setup of the Supercube system. 

414 This ratio can be read out. 

415 

416 :param channel: number of the input channel (1..4) 

417 :return: the ratio 

418 :raises ValueError: when channel is not valid 

419 """ 

420 

421 return float(self.read(constants.MeasurementsDividerRatio.input(channel))) 

422 

423 def get_measurement_voltage(self, channel: int) -> float: 

424 """ 

425 Get the measured voltage of an analog input channel. The voltage read out 

426 here is already scaled by the configured divider ratio. 

427 

428 :param channel: number of the input channel (1..4) 

429 :return: measured voltage 

430 :raises ValueError: when channel is not valid 

431 """ 

432 

433 return float(self.read(constants.MeasurementsScaledInput.input(channel))) 

434 

435 def get_earthing_stick_status(self, number: int) -> constants.EarthingStickStatus: 

436 """ 

437 Get the status of an earthing stick, whether it is closed, open or undefined 

438 (moving). 

439 

440 :param number: number of the earthing stick (1..6) 

441 :return: earthing stick status 

442 :raises ValueError: when earthing stick number is not valid 

443 """ 

444 

445 return constants.EarthingStickStatus( 

446 self.read(constants.EarthingStick.status(number)) 

447 ) 

448 

449 def get_earthing_stick_operating_status( 

450 self, number: int 

451 ) -> constants.EarthingStickOperatingStatus: 

452 """ 

453 Get the operating status of an earthing stick. 

454 

455 :param number: number of the earthing stick (1..6) 

456 :return: earthing stick operating status (auto == 0, manual == 1) 

457 :raises ValueError: when earthing stick number is not valid 

458 """ 

459 return constants.EarthingStickOperatingStatus( 

460 self.read(constants.EarthingStick.operating_status(number)) 

461 ) 

462 

463 def get_earthing_stick_manual( 

464 self, number: int 

465 ) -> constants.EarthingStickOperation: 

466 """ 

467 Get the manual status of an earthing stick. If an earthing stick is set to 

468 manual, it is closed even if the system is in states RedReady or RedOperate. 

469 

470 :param number: number of the earthing stick (1..6) 

471 :return: operation of the earthing stick in a manual operating mode (open == 0, 

472 close == 1) 

473 :raises ValueError: when earthing stick number is not valid 

474 """ 

475 return constants.EarthingStickOperation( 

476 int(self.read(constants.EarthingStick.manual(number))) 

477 ) 

478 

479 def operate_earthing_stick( 

480 self, number: int, operation: constants.EarthingStickOperation 

481 ) -> None: 

482 """ 

483 Operation of an earthing stick, which is set to manual operation. If an earthing 

484 stick is set to manual, it stays closed even if the system is in states 

485 RedReady or RedOperate. 

486 

487 :param number: number of the earthing stick (1..6) 

488 :param operation: earthing stick manual status (close or open) 

489 :raises SupercubeEarthingStickOperationError: when operating status of given 

490 number's earthing stick is not manual 

491 """ 

492 

493 if ( 

494 self.get_earthing_stick_operating_status(number) 

495 == constants.EarthingStickOperatingStatus.manual 

496 ): 

497 self.write(constants.EarthingStick.manual(number), bool(operation.value)) 

498 else: 

499 raise SupercubeEarthingStickOperationError 

500 

501 def quit_error(self) -> None: 

502 """ 

503 Quits errors that are active on the Supercube. 

504 """ 

505 

506 self.write(constants.Errors.quit, True) 

507 sleep(0.1) 

508 self.write(constants.Errors.quit, False) 

509 

510 def get_door_status(self, door: int) -> constants.DoorStatus: 

511 """ 

512 Get the status of a safety fence door. See :class:`constants.DoorStatus` for 

513 possible returned door statuses. 

514 

515 :param door: the door number (1..3) 

516 :return: the door status 

517 """ 

518 

519 return constants.DoorStatus(self.read(constants.Door.status(door))) 

520 

521 def get_earthing_rod_status(self, earthing_rod: int) -> constants.EarthingRodStatus: 

522 """ 

523 Get the status of a earthing rod. See :class:`constants.EarthingRodStatus` for 

524 possible returned earthing rod statuses. 

525 

526 :param earthing_rod: the earthing rod number (1..3) 

527 :return: the earthing rod status 

528 """ 

529 value = self.read(constants.EarthingRod.status(earthing_rod)) 

530 return constants.EarthingRodStatus(value) 

531 

532 def set_status_board( 

533 self, msgs: List[str], 

534 pos: List[int] = None, 

535 clear_board: bool = True, 

536 display_board: bool = True, 

537 ) -> None: 

538 """ 

539 Sets and displays a status board. The messages and the position of the message 

540 can be defined. 

541 

542 :param msgs: list of strings 

543 :param pos: list of integers [0...14] 

544 :param clear_board: clear unspecified lines if `True` (default), keep otherwise 

545 :param display_board: display new status board if `True` (default) 

546 :raises ValueError: if there are too many messages or the positions indices are 

547 invalid. 

548 """ 

549 # validate inputs 

550 if len(msgs) > self.message_len: 

551 raise ValueError( 

552 f"Too many message: {len(msgs)} given, max. {self.message_len} allowed." 

553 ) 

554 if pos and not all(0 < p < self.message_len for p in pos): 

555 raise ValueError(f"Messages positions out of 0...{self.message_len} range") 

556 

557 if clear_board: 

558 self.status_board = [""] * self.message_len 

559 

560 # update status board 

561 if not pos: 

562 pos = list(range(len(msgs))) 

563 for num, msg in zip(pos, msgs): 

564 self.status_board[num] = msg 

565 if display_board: 

566 self.display_status_board() 

567 

568 def display_status_board(self) -> None: 

569 """ 

570 Display status board. 

571 """ 

572 return self._display_messages(self.status_board) 

573 

574 def set_message_board(self, msgs: List[str], display_board: bool = True) -> None: 

575 """ 

576 Fills messages into message board that display that 15 newest messages with 

577 a timestamp. 

578 

579 :param msgs: list of strings 

580 :param display_board: display 15 newest messages if `True` (default) 

581 :raises ValueError: if there are too many messages or the positions indices are 

582 invalid. 

583 """ 

584 # validate inputs 

585 if len(msgs) > self.message_len: 

586 raise ValueError( 

587 f"Too many message: {len(msgs)} given, max. {self.message_len} allowed." 

588 ) 

589 

590 timestamp = datetime.now().time().strftime("%H:%M:%S") 

591 # append messages in the same order as given, not reversed 

592 self.message_board.extendleft( 

593 f"{timestamp}: {msg}" for msg in reversed(msgs) 

594 ) 

595 

596 if display_board: 

597 self.display_message_board() 

598 

599 def display_message_board(self) -> None: 

600 """ 

601 Display 15 newest messages 

602 """ 

603 return self._display_messages(self.message_board) 

604 

605 def _display_messages(self, messages: Sequence[str]) -> None: 

606 """ 

607 Display given messages on message board 

608 

609 :param messages: sequence of messages to display 

610 """ 

611 # Note: cannot zip(constants.MessageBoard, messages) as enum instances are 

612 # sorted by name, hence after after `line_1` comes `line_10`, not `line_2` 

613 for n, msg in enumerate(messages): 

614 line = constants.MessageBoard.line(n+1) 

615 self.write(line, msg)