Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\dev\supercube\base.py: 0%

# Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\dev\supercube\base.py : 0%

## 196 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 Base classes for the Supercube device.

5"""

6

7 import logging

8 from collections import deque

9 from datetime import datetime

10 from itertools import cycle

11 from time import sleep

12 from typing import List, Sequence

13

14 from opcua import Node

15

16 from . import constants

17 from ..base import SingleCommDevice

18 from ..utils import Poller

19 from ...comm import (

20 OpcUaCommunication,

21 OpcUaCommunicationConfig,

22 OpcUaSubHandler,

23)

24 from ...configuration import configdataclass

25 from ...utils.typing import Number

26

27

28 class SupercubeEarthingStickOperationError(Exception):

29 pass

30

31

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

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

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

114 class SupercubeOpcUaCommunication(OpcUaCommunication):

115 """

116 Communication protocol specification for Supercube devices.

117 """

118

119 @staticmethod

120 def config_cls():

121 return SupercubeOpcUaCommunicationConfig

122

123

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